From 611573a86cd7fd13db4e2a3b5f6102e1c5297642 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 09:54:21 -0500 Subject: [PATCH 01/23] Add temporary GHA_PAT delete-scope test workflow --- .github/workflows/pat-delete-test.yml | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/pat-delete-test.yml diff --git a/.github/workflows/pat-delete-test.yml b/.github/workflows/pat-delete-test.yml new file mode 100644 index 000000000..b1ed550c5 --- /dev/null +++ b/.github/workflows/pat-delete-test.yml @@ -0,0 +1,33 @@ +name: PAT delete test + +# Temporary: verifies secrets.GHA_PAT can delete a GHCR package version +# (i.e. carries delete:packages). Deletes exactly the version id passed in. +# Remove this workflow after the one-off check. + +on: + workflow_dispatch: + inputs: + package: + description: 'Container package name' + default: dota + version_id: + description: 'Exact version id to delete (must be a safe orphan)' + required: true + +permissions: + packages: write + +jobs: + test: + runs-on: ubuntu-latest + environment: prod + steps: + - name: Attempt delete with GHA_PAT + env: + GH_TOKEN: ${{ secrets.GHA_PAT }} + run: | + set -x + gh api -X DELETE \ + "/orgs/${{ github.repository_owner }}/packages/container/${{ inputs.package }}/versions/${{ inputs.version_id }}" \ + && echo "RESULT=DELETE_OK (GHA_PAT has delete:packages)" \ + || { echo "RESULT=DELETE_FAILED (likely missing delete:packages)"; exit 1; } From 95fa5f39df5d3903ebc93903137fe453ed37c1f6 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 09:55:15 -0500 Subject: [PATCH 02/23] Remove temporary GHA_PAT delete-scope test workflow --- .github/workflows/pat-delete-test.yml | 33 --------------------------- 1 file changed, 33 deletions(-) delete mode 100644 .github/workflows/pat-delete-test.yml diff --git a/.github/workflows/pat-delete-test.yml b/.github/workflows/pat-delete-test.yml deleted file mode 100644 index b1ed550c5..000000000 --- a/.github/workflows/pat-delete-test.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: PAT delete test - -# Temporary: verifies secrets.GHA_PAT can delete a GHCR package version -# (i.e. carries delete:packages). Deletes exactly the version id passed in. -# Remove this workflow after the one-off check. - -on: - workflow_dispatch: - inputs: - package: - description: 'Container package name' - default: dota - version_id: - description: 'Exact version id to delete (must be a safe orphan)' - required: true - -permissions: - packages: write - -jobs: - test: - runs-on: ubuntu-latest - environment: prod - steps: - - name: Attempt delete with GHA_PAT - env: - GH_TOKEN: ${{ secrets.GHA_PAT }} - run: | - set -x - gh api -X DELETE \ - "/orgs/${{ github.repository_owner }}/packages/container/${{ inputs.package }}/versions/${{ inputs.version_id }}" \ - && echo "RESULT=DELETE_OK (GHA_PAT has delete:packages)" \ - || { echo "RESULT=DELETE_FAILED (likely missing delete:packages)"; exit 1; } From a1e459c472b4ad7802e3f909cb2b59667db725fa Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 10:15:12 -0500 Subject: [PATCH 03/23] Unify builder.yml into a single service matrix The dota job duplicated the services matrix job's buildx/login/build steps, and every job computed 5-7 granular *_changed outputs that only ever fed one OR condition repeated across four steps. Collapse to a single build matrix (dota becomes a 4th entry with an extended paths regex) gated on one should_build grep. 165 -> 75 lines, identical build/push/tag/label behavior. Verified change-detection equivalence exhaustively: all 4096 changed-file category combinations produce the same per-service build decision as before. Also adds fail-fast: false so one service's failure no longer cancels others. --- .github/workflows/builder.yml | 111 +++++++--------------------------- 1 file changed, 22 insertions(+), 89 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index d3331134b..6f3ae18fa 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -19,118 +19,51 @@ on: - '.github/workflows/builder.yml' # The build workflow itself (affects all images) jobs: - # Dota job remains separate because it has extra change checks - dota: - runs-on: ubuntu-latest - environment: prod - permissions: - packages: write - 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 - - - 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 - with: - driver-opts: | - network=host - image=moby/buildkit:latest - buildkitd-flags: --debug - - - 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= - - # Combined job for twitch-chat, steam, and twitch-events using a matrix - services: + build: runs-on: ubuntu-latest environment: prod permissions: packages: write strategy: + fail-fast: false matrix: include: + - service: dota + paths: '^packages/(dota|profanity-filter|shared-utils)/|^packages/Dockerfile\.bun|^docker-compose\.yml|^package\.json|^bun\.lock|^\.github/workflows/builder\.yml' - service: twitch-chat - changed_path: "^packages/twitch-chat/" - dockerfile: "packages/Dockerfile.bun" + paths: '^packages/(twitch-chat|shared-utils)/|^packages/Dockerfile\.bun|^docker-compose\.yml|^\.github/workflows/builder\.yml' - service: steam - changed_path: "^packages/steam/" - dockerfile: "packages/Dockerfile.bun" + paths: '^packages/(steam|shared-utils)/|^packages/Dockerfile\.bun|^docker-compose\.yml|^\.github/workflows/builder\.yml' - service: twitch-events - changed_path: "^packages/twitch-events/" - dockerfile: "packages/Dockerfile.bun" + paths: '^packages/(twitch-events|shared-utils)/|^packages/Dockerfile\.bun|^docker-compose\.yml|^\.github/workflows/builder\.yml' steps: - uses: actions/checkout@v6 with: fetch-depth: 2 - - name: Check for changes in ${{ matrix.service }} service + - name: Check for relevant changes 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 + if [ "${{ github.event_name }}" = "workflow_dispatch" ] || \ + git diff --name-only HEAD^ HEAD | grep -qE "${{ matrix.paths }}"; then + echo "should_build=true" >> "$GITHUB_OUTPUT" + else + echo "should_build=false" >> "$GITHUB_OUTPUT" + fi - 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' }} + if: steps.changes.outputs.should_build == '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' }} + if: steps.changes.outputs.should_build == 'true' uses: docker/setup-buildx-action@v4 with: driver-opts: | @@ -139,7 +72,7 @@ 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' }} + if: steps.changes.outputs.should_build == 'true' uses: docker/login-action@v4 with: registry: ghcr.io @@ -147,7 +80,7 @@ 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' }} + if: steps.changes.outputs.should_build == 'true' uses: docker/bake-action@v7 with: push: true From 6880fe3c3632ddb7f3c919f862da5e10cb7aab61 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 10:31:13 -0500 Subject: [PATCH 04/23] Disable unused minimap parse on the GSI hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimap-blocker overlay is unused in production, but minimapParser.init ran on every GSI tick for any beta_tester with the setting on — parsing entity positions and serializing them over socket.io to nobody. Comment out the per-tick block (and its now-unused import) so the work is skipped; uncomment to revive. emitMinimapBlockerStatus (connect/event-driven, not per-tick) is left intact. --- .../src/dota/events/gsi-events/newdata.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) 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 From b1dd733f9fa0a89d8dbbf320db185de2fecbd123 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 10:51:35 -0500 Subject: [PATCH 05/23] Enable per-service GHA build cache; drop dead registry-cache config Image builds were cold every run: the bake set overrode cache with empty *.cache-from=/*.cache-to=, while docker-compose.yml still declared registry cache refs the workflow ignored. The earlier GHA-cache attempt failed only because it shared one scope across all services (scope=github.workflow), so they clobbered each other (~0% hits). Use type=gha scoped per matrix.service with mode=max (the bun install lives in an intermediate stage, so mode=max is required to cache it). Remove the unused x-bake registry cache blocks from compose so config matches reality. The 4 stale ghcr cache-* packages were deleted separately. --- .github/workflows/builder.yml | 4 ++-- docker-compose.yml | 20 -------------------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 6f3ae18fa..afa0fb72d 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -93,5 +93,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/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 From 36d61fce11a5a069e04a0951281a0798ba68a007 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 11:22:47 -0500 Subject: [PATCH 06/23] Harden CI/CD: fix push-range detection, add concurrency, pin Bun builder.yml: replace the dual hand-maintained path lists + last-commit-only git diff with a single dorny/paths-filter pre-job that diffs the full push range. Fixes two rebuild-skip bugs: multi-commit pushes where an earlier commit touched a service, and root package.json/bun.lock changes that previously rebuilt only dota. The build matrix is now derived from the filter output, so only changed services spin up runners. Add concurrency groups to builder.yml and ci.yml so superseded runs on a branch are cancelled. Pin CI/health-check Bun to 1.3.14 to match the prod image. Drop the unused profanity-filter Dockerfile. --- .github/workflows/builder.yml | 104 +++++++++++------- .github/workflows/ci.yml | 6 +- .../workflows/subscription-health-check.yml | 2 +- packages/profanity-filter/Dockerfile | 35 ------ 4 files changed, 73 insertions(+), 74 deletions(-) delete mode 100644 packages/profanity-filter/Dockerfile diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index afa0fb72d..e2193014e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -5,21 +5,75 @@ 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: + changes: + runs-on: ubuntu-latest + outputs: + services: ${{ steps.select.outputs.services }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + # 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: + 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: 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 + build: + needs: changes + if: needs.changes.outputs.services != '[]' runs-on: ubuntu-latest environment: prod permissions: @@ -27,32 +81,11 @@ jobs: strategy: fail-fast: false matrix: - include: - - service: dota - paths: '^packages/(dota|profanity-filter|shared-utils)/|^packages/Dockerfile\.bun|^docker-compose\.yml|^package\.json|^bun\.lock|^\.github/workflows/builder\.yml' - - service: twitch-chat - paths: '^packages/(twitch-chat|shared-utils)/|^packages/Dockerfile\.bun|^docker-compose\.yml|^\.github/workflows/builder\.yml' - - service: steam - paths: '^packages/(steam|shared-utils)/|^packages/Dockerfile\.bun|^docker-compose\.yml|^\.github/workflows/builder\.yml' - - service: twitch-events - paths: '^packages/(twitch-events|shared-utils)/|^packages/Dockerfile\.bun|^docker-compose\.yml|^\.github/workflows/builder\.yml' + service: ${{ fromJSON(needs.changes.outputs.services) }} steps: - uses: actions/checkout@v6 - with: - fetch-depth: 2 - - - name: Check for relevant changes - id: changes - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ] || \ - git diff --name-only HEAD^ HEAD | grep -qE "${{ matrix.paths }}"; then - echo "should_build=true" >> "$GITHUB_OUTPUT" - else - echo "should_build=false" >> "$GITHUB_OUTPUT" - fi - name: Extract branch name - if: steps.changes.outputs.should_build == 'true' shell: bash run: | echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> "$GITHUB_ENV" @@ -63,7 +96,6 @@ jobs: echo "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV" - name: Set up Docker Buildx - if: steps.changes.outputs.should_build == 'true' uses: docker/setup-buildx-action@v4 with: driver-opts: | @@ -72,7 +104,6 @@ jobs: buildkitd-flags: --debug - name: Login to ghcr - if: steps.changes.outputs.should_build == 'true' uses: docker/login-action@v4 with: registry: ghcr.io @@ -80,7 +111,6 @@ jobs: password: ${{ secrets.GHA_PAT }} - name: Build and push - if: steps.changes.outputs.should_build == 'true' uses: docker/bake-action@v7 with: push: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75fa6d328..e3f2fd4da 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,7 +17,7 @@ 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 with: 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/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"] From f7080743b80abe9c1f92785039f7526e9121c561 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 13:06:47 -0500 Subject: [PATCH 07/23] Fix eventsub/events connection flags getting stuck false on reconnect A stale socket closing after a reconnect handoff clobbered the live connection's status flag, so uptime monitors reported "disconnected" while events kept flowing. twitch-chat now ignores closes from non-current sockets and treats keepalives/notifications as liveness; twitch-events derives connectedness from a connected-client count instead of a clobberable boolean (and drops dead client-only socket handlers). --- packages/twitch-chat/src/eventSubSocket.ts | 10 +++++++ .../twitch-events/src/utils/socketUtils.ts | 30 +++++-------------- 2 files changed, 18 insertions(+), 22 deletions(-) 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-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 From ff66b37357561b5fade493126f7a4524040d47e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 13:19:43 -0500 Subject: [PATCH 08/23] build(deps): bump nick-fields/retry from 3 to 4 (#595) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3f2fd4da..c20e3b68d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: 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 From 06505577a8d289bbd74efb5644230217e22789c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 13:20:19 -0500 Subject: [PATCH 09/23] build(deps): bump actions/cache from 4 to 5 (#594) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c20e3b68d..208588662 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: with: 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') }} From 762024a8cefc2bdc05a4e05e1ceb14d8ad27ad31 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 17:36:14 -0500 Subject: [PATCH 10/23] Drop Valve-disabled note from avg/gm/np/smurfs commands These commands still return real results, so the appended Oct 9 explainer is no longer needed. --- packages/dota/src/twitch/commands/avg.ts | 15 +++------------ packages/dota/src/twitch/commands/gm.ts | 17 +++-------------- packages/dota/src/twitch/commands/np.ts | 14 ++------------ packages/dota/src/twitch/commands/smurfs.ts | 12 +++--------- 4 files changed, 11 insertions(+), 47 deletions(-) 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( From 074d3a2c39fa27a49781761c3f4c894de3480bb5 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 21:09:00 -0500 Subject: [PATCH 11/23] test(clip-processor-py): expand suite to surface prod bugs Add offline tests across api_server (HTTP routes, image-serving security, auth, stuck-request reset, stream requests), postgresql_client (match/draft fetch, queue state, facet merge), dota_heroes helpers, clip_utils URL/error paths, stream_processor state + backoff, and facet_detection seams. Five tests fail intentionally to pin real bugs for follow-up: - get_clip_result(_by_match_id) swallows a facets/player KeyError and returns None, silently invalidating an otherwise-valid cached result. - extract_clip_id returns "" (not None) for path-bearing junk URLs, which can collide as a cache/queue-dedup key. --- packages/clip-processor-py/.gitignore | 6 + packages/clip-processor-py/pyproject.toml | 22 + packages/clip-processor-py/test/test_queue.py | 209 -------- packages/clip-processor-py/tests/conftest.py | 73 +++ .../clip-processor-py/tests/test_alignment.py | 196 ++++++++ .../tests/test_api_server.py | 461 +++++++++++------- .../tests/test_api_server_http.py | 128 +++++ .../tests/test_clip_utils.py | 288 +++++++---- .../tests/test_dota_heroes.py | 315 +++++++----- .../tests/test_facet_detection.py | 93 ++++ .../tests/test_postgresql_client.py | 352 +++++++++++++ .../tests/test_stream_processor.py | 123 +++++ .../tests/test_stream_utils.py | 66 +++ packages/clip-processor-py/uv.lock | 433 ++++++++++++++++ 14 files changed, 2175 insertions(+), 590 deletions(-) create mode 100644 packages/clip-processor-py/pyproject.toml delete mode 100644 packages/clip-processor-py/test/test_queue.py create mode 100644 packages/clip-processor-py/tests/conftest.py create mode 100644 packages/clip-processor-py/tests/test_alignment.py create mode 100644 packages/clip-processor-py/tests/test_api_server_http.py create mode 100644 packages/clip-processor-py/tests/test_facet_detection.py create mode 100644 packages/clip-processor-py/tests/test_postgresql_client.py create mode 100644 packages/clip-processor-py/tests/test_stream_processor.py create mode 100644 packages/clip-processor-py/tests/test_stream_utils.py create mode 100644 packages/clip-processor-py/uv.lock 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/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..9acabf4f9 --- /dev/null +++ b/packages/clip-processor-py/tests/test_alignment.py @@ -0,0 +1,196 @@ +"""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_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_do_not_align_well(): + # Documents a real limitation: _normalize_name strips spaces, collapsing + # "Team Liquid" / "Liquid Team" into single tokens, so the token-overlap path + # cannot recover word-swapped names. They still get *assigned* here (refine has + # no threshold), but only via the weak difflib-ratio fallback, not containment. + players = _p("Liquid Team") + draft = ["Team Liquid"] + mapping, _ = _refine_alignment_with_captains_and_leftovers( + {}, players, draft, draft_info={} + ) + # assigned (no threshold in refine) but not by containment + 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..a72cee39d 100644 --- a/packages/clip-processor-py/tests/test_api_server.py +++ b/packages/clip-processor-py/tests/test_api_server.py @@ -1,178 +1,309 @@ -#!/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 - # 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 + +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_queue_path_returns_queue_info(monkeypatch): + 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"): + result = api_server.process_stream_request("streamer", add_to_queue=True) + assert result["queued"] is True + assert result["request_id"] == "rid" + assert result["status"] == "pending" + assert result["position"] == 2 + + +# --------------------------------------------------------------------------- # +# 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 + - # 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_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 + + # 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 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_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" - # 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_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 + +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..331626df5 --- /dev/null +++ b/packages/clip-processor-py/tests/test_facet_detection.py @@ -0,0 +1,93 @@ +"""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 test_extract_facet_region_radiant_uses_top_left_corner(): + # A fake portrait: .shape gives (h, w); slicing returns a sentinel whose own + # slicing/shape is benign. We assert the Radiant branch starts at the left + # edge (x == FACET_SIDE_MARGIN) by capturing the slice indices. + captured = {} + + class FakePortrait: + shape = (72, 108, 3) + + def __getitem__(self, key): + captured["yx"] = key + region = MagicMock() + region.shape = (28, 28, 3) # color -> triggers cvtColor path (stubbed) + return region + + facet_detection.extract_facet_region(FakePortrait(), "Radiant") + y_slice, 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(): + captured = {} + + class FakePortrait: + shape = (72, 108, 3) + + def __getitem__(self, key): + captured["yx"] = key + region = MagicMock() + region.shape = (28, 28, 3) + return region + + facet_detection.extract_facet_region(FakePortrait(), "Dire") + y_slice, 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" }, +] From 063ecd0ab743d7acaf0491405fc490cd3780f8c7 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 21:15:03 -0500 Subject: [PATCH 12/23] fix(clip-processor-py): stop silent cache loss and empty clip IDs - get_clip_result(_by_match_id): guard facet merge with facets.get(team, []) and skip players/heroes missing team/position, so a partial or legacy facets payload no longer raises a swallowed KeyError that discarded the whole cached result and forced needless reprocessing. - extract_clip_id: drop empty path segments so path-bearing junk URLs return None instead of "", which could collide as a cache/queue-dedup key. Turns the five intentionally-failing tests from the prior commit green. --- packages/clip-processor-py/src/api_server.py | 2 +- .../src/postgresql_client.py | 28 ++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/clip-processor-py/src/api_server.py b/packages/clip-processor-py/src/api_server.py index 115682444..2008fa819 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] diff --git a/packages/clip-processor-py/src/postgresql_client.py b/packages/clip-processor-py/src/postgresql_client.py index 522e9e53e..53af6c57e 100644 --- a/packages/clip-processor-py/src/postgresql_client.py +++ b/packages/clip-processor-py/src/postgresql_client.py @@ -263,11 +263,13 @@ def get_clip_result(self, clip_id: str) -> Optional[Dict[str, Any]]: # Add facets to players array if 'players' in result: for player in result['players']: - team = player['team'].lower() - position = player['position'] + team = (player.get('team') or '').lower() + position = player.get('position') + if not team or position is None: + continue # Find matching facet info - for hero_facet in facets[team]: + for hero_facet in facets.get(team, []): if hero_facet['position'] == position: player['facet'] = hero_facet['facet'] break @@ -275,11 +277,13 @@ def get_clip_result(self, clip_id: str) -> Optional[Dict[str, Any]]: # Also add facets to heroes array if present if 'heroes' in result: for hero in result['heroes']: - team = hero['team'].lower() + team = (hero.get('team') or '').lower() + if not team or hero.get('position') is None: + continue position = hero['position'] + 1 # Convert to 1-indexed # Find matching facet info - for hero_facet in facets[team]: + for hero_facet in facets.get(team, []): if hero_facet['position'] == position: hero['facet'] = hero_facet['facet'] break @@ -340,11 +344,13 @@ def get_clip_result_by_match_id(self, match_id: str) -> Optional[Dict[str, Any]] if facets: if 'players' in result: for player in result['players']: - team = player['team'].lower() - position = player['position'] + team = (player.get('team') or '').lower() + position = player.get('position') + if not team or position is None: + continue # Find matching facet info - for hero_facet in facets[team]: + for hero_facet in facets.get(team, []): if hero_facet['position'] == position: player['facet'] = hero_facet['facet'] break @@ -352,11 +358,13 @@ def get_clip_result_by_match_id(self, match_id: str) -> Optional[Dict[str, Any]] # Also add facets to heroes array if present if 'heroes' in result: for hero in result['heroes']: - team = hero['team'].lower() + team = (hero.get('team') or '').lower() + if not team or hero.get('position') is None: + continue position = hero['position'] + 1 # Convert to 1-indexed # Find matching facet info - for hero_facet in facets[team]: + for hero_facet in facets.get(team, []): if hero_facet['position'] == position: hero['facet'] = hero_facet['facet'] break From 54b23ef8ffea3b0e7d5a8e92bbd82d5401d36871 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 21:17:40 -0500 Subject: [PATCH 13/23] test: expand coverage across twitch-events, twitch-chat, shared-utils, and dota Wire up test infra (bunfig + test script + test:all) for twitch-events and twitch-chat, which previously had no tests, and add offline/mocked suites: - twitch-events: RateLimiter, isAuthenticated, runSubscriptionHealthCheck - twitch-chat: handleChatMessage/sendTwitchChatMessage, bet/poll transforms - shared-utils: conduitManager fetch/retry/cache - dota: ranks math, subscription gating, and 27 chat commands All suites run fully offline with zero skipped tests. --- package.json | 2 +- .../dota/src/dota/lib/__tests__/ranks.test.ts | 91 +++++++++ .../__tests__/gsiCommands.integration.test.ts | 144 ++++++++++++++ .../__tests__/modCommands.integration.test.ts | 65 +++++++ .../settingsCommands.integration.test.ts | 83 ++++++++ .../src/twitch/lib/__tests__/setupMocks.ts | 24 +++ .../simpleCommands.integration.test.ts | 125 ++++++++++++ .../src/utils/__tests__/subscription.test.ts | 84 +++++++++ .../shared-utils/tests/conduitManager.test.ts | 120 ++++++++++++ packages/twitch-chat/bunfig.toml | 2 + packages/twitch-chat/package.json | 1 + .../src/__tests__/handleChat.test.ts | 178 ++++++++++++++++++ .../twitch-chat/src/__tests__/sharedMocks.ts | 104 ++++++++++ .../__tests__/transformBetData.test.ts | 61 ++++++ .../__tests__/transformPollData.test.ts | 34 ++++ packages/twitch-events/bunfig.toml | 2 + packages/twitch-events/package.json | 1 + .../src/__tests__/sharedMocks.ts | 92 +++++++++ .../src/utils/__tests__/authUtils.test.ts | 31 +++ .../utils/__tests__/rateLimiterCore.test.ts | 101 ++++++++++ .../__tests__/subscriptionHealthCheck.test.ts | 99 ++++++++++ 21 files changed, 1443 insertions(+), 1 deletion(-) create mode 100644 packages/dota/src/dota/lib/__tests__/ranks.test.ts create mode 100644 packages/dota/src/twitch/lib/__tests__/gsiCommands.integration.test.ts create mode 100644 packages/dota/src/twitch/lib/__tests__/modCommands.integration.test.ts create mode 100644 packages/dota/src/twitch/lib/__tests__/settingsCommands.integration.test.ts create mode 100644 packages/dota/src/twitch/lib/__tests__/simpleCommands.integration.test.ts create mode 100644 packages/dota/src/utils/__tests__/subscription.test.ts create mode 100644 packages/shared-utils/tests/conduitManager.test.ts create mode 100644 packages/twitch-chat/bunfig.toml create mode 100644 packages/twitch-chat/src/__tests__/handleChat.test.ts create mode 100644 packages/twitch-chat/src/__tests__/sharedMocks.ts create mode 100644 packages/twitch-chat/src/event-handlers/__tests__/transformBetData.test.ts create mode 100644 packages/twitch-chat/src/event-handlers/__tests__/transformPollData.test.ts create mode 100644 packages/twitch-events/bunfig.toml create mode 100644 packages/twitch-events/src/__tests__/sharedMocks.ts create mode 100644 packages/twitch-events/src/utils/__tests__/authUtils.test.ts create mode 100644 packages/twitch-events/src/utils/__tests__/rateLimiterCore.test.ts create mode 100644 packages/twitch-events/src/utils/__tests__/subscriptionHealthCheck.test.ts 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/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/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__/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__/settingsCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/settingsCommands.integration.test.ts new file mode 100644 index 000000000..06d38a453 --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/settingsCommands.integration.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +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: '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: '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..5bb619416 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 @@ -72,6 +73,7 @@ export const state: { redisGet: {}, redisDelCalls: [], updateCalls: [], + upsertCalls: [], updateMmrCalls: [], chatSayCalls: [], steamSocketResponse: null, @@ -102,6 +104,7 @@ export function resetState() { state.redisGet = {} state.redisDelCalls = [] state.updateCalls = [] + state.upsertCalls = [] state.updateMmrCalls = [] state.chatSayCalls = [] state.steamSocketResponse = null @@ -145,6 +148,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 @@ -296,6 +303,23 @@ 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') // 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/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/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..fa112746e --- /dev/null +++ b/packages/twitch-chat/src/__tests__/sharedMocks.ts @@ -0,0 +1,104 @@ +// 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 = [] +} + +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 } = 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-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..eef268ac6 --- /dev/null +++ b/packages/twitch-events/src/utils/__tests__/rateLimiterCore.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { RateLimiter, resetState } from '../../__tests__/sharedMocks.ts' + +const makeHeaders = (h: Record) => new Headers(h) + +describe('RateLimiter', () => { + beforeEach(() => { + resetState() + }) + + 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 () => { + const rl = new RateLimiter() + // remaining 0, reset ~15ms ahead -> exercises the wait branch deterministically. + rl.updateLimits( + makeHeaders({ + 'Ratelimit-Limit': '50', + 'Ratelimit-Remaining': '0', + 'Ratelimit-Reset': String(Math.ceil((Date.now() + 15) / 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) + }) +}) From b83429da2f9f303c0b75fc91bcddad05d2eb5729 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 21:19:23 -0500 Subject: [PATCH 14/23] refactor(clip-processor-py): dedupe facet-merge into one helper The facet-merge guards were copy-pasted verbatim across get_clip_result and get_clip_result_by_match_id, so a future change had to be applied twice or silently diverge. Extract a single _merge_facets_into_result helper used by both. Also hoist the duplicated FakePortrait test double into a shared _recording_portrait() helper. --- .../src/postgresql_client.py | 90 +++++++------------ .../tests/test_facet_detection.py | 34 +++---- 2 files changed, 47 insertions(+), 77 deletions(-) diff --git a/packages/clip-processor-py/src/postgresql_client.py b/packages/clip-processor-py/src/postgresql_client.py index 53af6c57e..a2a6609f6 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,35 +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.get('team') or '').lower() - position = player.get('position') - if not team or position is None: - continue - - # Find matching facet info - for hero_facet in facets.get(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.get('team') or '').lower() - if not team or hero.get('position') is None: - continue - position = hero['position'] + 1 # Convert to 1-indexed - - # Find matching facet info - for hero_facet in facets.get(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}") @@ -336,38 +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.get('team') or '').lower() - position = player.get('position') - if not team or position is None: - continue - - # Find matching facet info - for hero_facet in facets.get(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.get('team') or '').lower() - if not team or hero.get('position') is None: - continue - position = hero['position'] + 1 # Convert to 1-indexed - - # Find matching facet info - for hero_facet in facets.get(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) diff --git a/packages/clip-processor-py/tests/test_facet_detection.py b/packages/clip-processor-py/tests/test_facet_detection.py index 331626df5..da4c60216 100644 --- a/packages/clip-processor-py/tests/test_facet_detection.py +++ b/packages/clip-processor-py/tests/test_facet_detection.py @@ -55,10 +55,9 @@ def test_extract_facet_region_none_portrait_returns_none(): assert facet_detection.extract_facet_region(None, "Radiant") is None -def test_extract_facet_region_radiant_uses_top_left_corner(): - # A fake portrait: .shape gives (h, w); slicing returns a sentinel whose own - # slicing/shape is benign. We assert the Radiant branch starts at the left - # edge (x == FACET_SIDE_MARGIN) by capturing the slice indices. +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: @@ -67,27 +66,22 @@ class FakePortrait: def __getitem__(self, key): captured["yx"] = key region = MagicMock() - region.shape = (28, 28, 3) # color -> triggers cvtColor path (stubbed) + region.shape = (28, 28, 3) # color -> triggers the stubbed cvtColor path return region - facet_detection.extract_facet_region(FakePortrait(), "Radiant") - y_slice, x_slice = captured["yx"] - assert x_slice.start == facet_detection.FACET_SIDE_MARGIN # left corner for Radiant + return FakePortrait(), captured -def test_extract_facet_region_dire_uses_right_corner(): - captured = {} - - class FakePortrait: - shape = (72, 108, 3) +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 __getitem__(self, key): - captured["yx"] = key - region = MagicMock() - region.shape = (28, 28, 3) - return region - facet_detection.extract_facet_region(FakePortrait(), "Dire") - y_slice, x_slice = captured["yx"] +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 From 5ca369c3f038e586740594c685e0b38e267d5bb5 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 21:19:46 -0500 Subject: [PATCH 15/23] test(dota): cover ranked and spectators via a mocked MongoDB layer Add a MongoDBSingleton mock to the twitch/lib test harness whose delayedGames findOne returns a controllable state.delayedGame, unlocking the match-data commands that read live game info from Mongo. --- .../matchDataCommands.integration.test.ts | 91 +++++++++++++++++++ .../src/twitch/lib/__tests__/setupMocks.ts | 19 ++++ 2 files changed, 110 insertions(+) create mode 100644 packages/dota/src/twitch/lib/__tests__/matchDataCommands.integration.test.ts 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..08ef4b0f8 --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/matchDataCommands.integration.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { commandHandler, makeMessage, resetState, state } from './setupMocks.ts' + +// Commands that read live match data from the (mocked) MongoDB delayedGames +// collection via state.delayedGame. +const LOBBY_TYPE_RANKED = 7 + +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 ranked_no 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) + }) + + 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) + }) + + 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) + }) + + 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__/setupMocks.ts b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts index 5bb619416..1152487f2 100644 --- a/packages/dota/src/twitch/lib/__tests__/setupMocks.ts +++ b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts @@ -66,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, @@ -95,6 +97,7 @@ export const state: { botBanned: false, subscriberOnlyMode: false, chatSettingsUpdates: [], + delayedGame: null, } export function resetState() { @@ -126,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: @@ -276,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. @@ -320,6 +337,8 @@ await import('../../commands/modsonly') await import('../../commands/only') await import('../../commands/setdelay') await import('../../commands/mute') +await import('../../commands/ranked') +await import('../../commands/spectators') // Monkey-patch the singletons we need behavior control over. Mocking these // modules wholesale would force us to enumerate every other transitive export. From a7afabf86db734ee9f3ebb1098f2bb6867baac91 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 21:20:57 -0500 Subject: [PATCH 16/23] test(dota): cover opendota, profile, and friends commands --- .../profileCommands.integration.test.ts | 78 +++++++++++++++++++ .../src/twitch/lib/__tests__/setupMocks.ts | 3 + 2 files changed, 81 insertions(+) create mode 100644 packages/dota/src/twitch/lib/__tests__/profileCommands.integration.test.ts 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__/setupMocks.ts b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts index 1152487f2..1686de4cb 100644 --- a/packages/dota/src/twitch/lib/__tests__/setupMocks.ts +++ b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts @@ -339,6 +339,9 @@ 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') // Monkey-patch the singletons we need behavior control over. Mocking these // modules wholesale would force us to enumerate every other transitive export. From 514d19e211e646d096681129f44082dbcf1f3b80 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 21:22:15 -0500 Subject: [PATCH 17/23] test(dota): cover beta, toggle, and today (no-steam paths) --- .../src/twitch/lib/__tests__/setupMocks.ts | 3 + .../toggleCommands.integration.test.ts | 66 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 packages/dota/src/twitch/lib/__tests__/toggleCommands.integration.test.ts diff --git a/packages/dota/src/twitch/lib/__tests__/setupMocks.ts b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts index 1686de4cb..9d5040c0b 100644 --- a/packages/dota/src/twitch/lib/__tests__/setupMocks.ts +++ b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts @@ -342,6 +342,9 @@ 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') // 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__/toggleCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/toggleCommands.integration.test.ts new file mode 100644 index 000000000..6c59a5204 --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/toggleCommands.integration.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { flushAsync } from '../../../__tests__/sharedMocks.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: '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') + }) +}) From 2650c77cc029bf79f1f2777901a583597d06482c Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 21:23:27 -0500 Subject: [PATCH 18/23] test(dota): cover clearsharing and lgs (no-steam paths) --- .../redisCommands.integration.test.ts | 45 +++++++++++++++++++ .../src/twitch/lib/__tests__/setupMocks.ts | 2 + 2 files changed, 47 insertions(+) create mode 100644 packages/dota/src/twitch/lib/__tests__/redisCommands.integration.test.ts 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__/setupMocks.ts b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts index 9d5040c0b..47c94870e 100644 --- a/packages/dota/src/twitch/lib/__tests__/setupMocks.ts +++ b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts @@ -345,6 +345,8 @@ await import('../../commands/profile') await import('../../commands/beta') await import('../../commands/toggle') await import('../../commands/today') +await import('../../commands/clearsharing') +await import('../../commands/lgs') // Monkey-patch the singletons we need behavior control over. Mocking these // modules wholesale would force us to enumerate every other transitive export. From f01a5ace45a596f39035a9b77359740295cd66eb Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 21:32:05 -0500 Subject: [PATCH 19/23] test: harden assertions and test isolation from review - assert real source constants (DBSettings, LOBBY_TYPE_RANKED) instead of literals - distinguish ranked yes/no by message content rather than just reply count - make the RateLimiter reset-wait test deterministic via a patched timer - reset the handleChat dedupe cache between tests to prevent cross-test leakage --- .../matchDataCommands.integration.test.ts | 10 +++++++--- .../settingsCommands.integration.test.ts | 5 +++-- .../toggleCommands.integration.test.ts | 6 +++++- .../twitch-chat/src/__tests__/sharedMocks.ts | 10 +++++++--- packages/twitch-chat/src/handleChat.ts | 5 +++++ .../utils/__tests__/rateLimiterCore.test.ts | 18 +++++++++++++++--- 6 files changed, 42 insertions(+), 12 deletions(-) diff --git a/packages/dota/src/twitch/lib/__tests__/matchDataCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/matchDataCommands.integration.test.ts index 08ef4b0f8..be2b1297b 100644 --- a/packages/dota/src/twitch/lib/__tests__/matchDataCommands.integration.test.ts +++ b/packages/dota/src/twitch/lib/__tests__/matchDataCommands.integration.test.ts @@ -1,10 +1,9 @@ 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 LOBBY_TYPE_RANKED = 7 - const liveGsi = () => ({ map: { matchid: '7777777777' } }) as any beforeEach(() => { @@ -54,7 +53,7 @@ describe('!ranked', () => { expect(state.chatSayCalls[0].message.toLowerCase()).toContain('steam') }) - it('reports ranked_no for a non-match lobby id of 0', async () => { + it('reports not-ranked for a non-match lobby id of 0', async () => { await commandHandler.handleMessage( makeMessage({ content: '!ranked', @@ -62,6 +61,7 @@ describe('!ranked', () => { }), ) 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 () => { @@ -70,6 +70,9 @@ describe('!ranked', () => { 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 () => { @@ -78,6 +81,7 @@ describe('!ranked', () => { 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 () => { diff --git a/packages/dota/src/twitch/lib/__tests__/settingsCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/settingsCommands.integration.test.ts index 06d38a453..4b8c3dd92 100644 --- a/packages/dota/src/twitch/lib/__tests__/settingsCommands.integration.test.ts +++ b/packages/dota/src/twitch/lib/__tests__/settingsCommands.integration.test.ts @@ -1,4 +1,5 @@ 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() @@ -19,7 +20,7 @@ describe('!setdelay', () => { 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: 'streamDelay', value: 5000 }) + expect(state.upsertCalls[0].values).toMatchObject({ key: DBSettings.streamDelay, value: 5000 }) expect(state.chatSayCalls[0].message).toContain('5') }) @@ -68,7 +69,7 @@ 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: 'chatter' }) + expect(state.upsertCalls[0].values).toMatchObject({ key: DBSettings.chatter }) expect(typeof state.upsertCalls[0].values.value).toBe('boolean') expect(state.chatSayCalls).toHaveLength(1) }) diff --git a/packages/dota/src/twitch/lib/__tests__/toggleCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/toggleCommands.integration.test.ts index 6c59a5204..71bf35ec3 100644 --- a/packages/dota/src/twitch/lib/__tests__/toggleCommands.integration.test.ts +++ b/packages/dota/src/twitch/lib/__tests__/toggleCommands.integration.test.ts @@ -1,5 +1,6 @@ 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 -> @@ -32,7 +33,10 @@ 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: 'commandDisable', value: true }) + expect(state.upsertCalls[0].values).toMatchObject({ + key: DBSettings.commandDisable, + value: true, + }) expect(state.chatSayCalls).toHaveLength(0) }) diff --git a/packages/twitch-chat/src/__tests__/sharedMocks.ts b/packages/twitch-chat/src/__tests__/sharedMocks.ts index fa112746e..c3e3fb310 100644 --- a/packages/twitch-chat/src/__tests__/sharedMocks.ts +++ b/packages/twitch-chat/src/__tests__/sharedMocks.ts @@ -57,6 +57,7 @@ export function resetState() { }) state.fetchThrows = null state.logError = [] + clearDedupeCache() } mock.module('@dotabod/shared-utils', () => ({ @@ -99,6 +100,9 @@ globalThis.fetch = (async (url: string, options: any) => { }) as unknown as typeof fetch // Import after mocks are registered. -export const { sendTwitchChatMessage, handleChatMessage, ChatMessageResponseCode } = await import( - '../handleChat' -) +export const { + sendTwitchChatMessage, + handleChatMessage, + ChatMessageResponseCode, + clearDedupeCache, +} = await import('../handleChat') 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/src/utils/__tests__/rateLimiterCore.test.ts b/packages/twitch-events/src/utils/__tests__/rateLimiterCore.test.ts index eef268ac6..c5244b5ed 100644 --- a/packages/twitch-events/src/utils/__tests__/rateLimiterCore.test.ts +++ b/packages/twitch-events/src/utils/__tests__/rateLimiterCore.test.ts @@ -1,13 +1,18 @@ -import { beforeEach, describe, expect, it } from 'bun:test' +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() @@ -68,13 +73,20 @@ describe('RateLimiter', () => { }) 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 ~15ms ahead -> exercises the wait branch deterministically. + // 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() + 15) / 1000)), + 'Ratelimit-Reset': String(Math.ceil((Date.now() + 60_000) / 1000)), }), ) From 69d57a781a1f7a9694eb7e7976cb016bc29fdd26 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 21:35:28 -0500 Subject: [PATCH 20/23] fix(clip-processor-py): repair draft alignment + queue dedup messaging Draft alignment: the token-overlap match path was dead code -- tokens were split from the already space-stripped normalized name, collapsing multi-word names to a single token. Word-swapped names ("Team Liquid" / "Liquid Team") fell below the difflib ratio threshold and went unmatched. Tokenize the raw name (normalizing each word) so token overlap works as documented. Queue dedup: a brand-new enqueue returns status 'pending', so it tripped the "already in the processing queue" branch -- giving a misleading message AND skipping start_worker_thread(). add_to_queue now flags real dedup hits with 'deduplicated', and the clip/stream paths branch on that instead of status. --- packages/clip-processor-py/src/api_server.py | 53 ++++++++++--------- .../src/postgresql_client.py | 6 +-- .../clip-processor-py/tests/test_alignment.py | 30 ++++++++--- .../tests/test_api_server.py | 40 ++++++++++++-- 4 files changed, 93 insertions(+), 36 deletions(-) diff --git a/packages/clip-processor-py/src/api_server.py b/packages/clip-processor-py/src/api_server.py index 2008fa819..9b5ca2665 100644 --- a/packages/clip-processor-py/src/api_server.py +++ b/packages/clip-processor-py/src/api_server.py @@ -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 a2a6609f6..0bf79410f 100644 --- a/packages/clip-processor-py/src/postgresql_client.py +++ b/packages/clip-processor-py/src/postgresql_client.py @@ -866,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: @@ -881,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} @@ -895,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/tests/test_alignment.py b/packages/clip-processor-py/tests/test_alignment.py index 9acabf4f9..9b7679b9a 100644 --- a/packages/clip-processor-py/tests/test_alignment.py +++ b/packages/clip-processor-py/tests/test_alignment.py @@ -90,6 +90,27 @@ def test_cyrillic_confusable_aligns_to_latin(): 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 @@ -182,15 +203,12 @@ def test_refine_captain_anchor_takes_precedence_over_ambiguous_leftover(): assert reordered[0]["player_name"] == "Cap" -def test_refine_word_reordered_team_names_do_not_align_well(): - # Documents a real limitation: _normalize_name strips spaces, collapsing - # "Team Liquid" / "Liquid Team" into single tokens, so the token-overlap path - # cannot recover word-swapped names. They still get *assigned* here (refine has - # no threshold), but only via the weak difflib-ratio fallback, not containment. +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={} ) - # assigned (no threshold in refine) but not by containment 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 a72cee39d..93ea3a365 100644 --- a/packages/clip-processor-py/tests/test_api_server.py +++ b/packages/clip-processor-py/tests/test_api_server.py @@ -121,17 +121,51 @@ def test_process_stream_request_direct_handles_no_result(): assert "error" in result -def test_process_stream_request_queue_path_returns_queue_info(monkeypatch): +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"): + 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["status"] == "pending" 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() # --------------------------------------------------------------------------- # From 54985b048ac3c408d4a3fad5bdfa43a4057bf9c0 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 21:40:48 -0500 Subject: [PATCH 21/23] test(dota): cover items and stats reachable paths With ENABLE_SPECTATE_FRIEND_GAME off, the non-spectator path returns the Valve-disabled message before any Redis/steam call, so these cover notPlaying, gameNotFound, the Valve-disabled branch, and alias routing without new mocks. --- .../matchStatsCommands.integration.test.ts | 72 +++++++++++++++++++ .../src/twitch/lib/__tests__/setupMocks.ts | 2 + 2 files changed, 74 insertions(+) create mode 100644 packages/dota/src/twitch/lib/__tests__/matchStatsCommands.integration.test.ts 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__/setupMocks.ts b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts index 47c94870e..29d385146 100644 --- a/packages/dota/src/twitch/lib/__tests__/setupMocks.ts +++ b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts @@ -347,6 +347,8 @@ await import('../../commands/toggle') await import('../../commands/today') await import('../../commands/clearsharing') await import('../../commands/lgs') +await import('../../commands/items') +await import('../../commands/stats') // Monkey-patch the singletons we need behavior control over. Mocking these // modules wholesale would force us to enumerate every other transitive export. From a832342301f6420865967054be8cec2ee379fbd4 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Wed, 20 May 2026 21:43:21 -0500 Subject: [PATCH 22/23] test(dota): cover geo plus the steam-guard branch of gm/np/smurfs/lg geo short-circuits to the Valve-disabled message before getAccountsFromMatch, so its reachable paths are fully covered. gm/np/smurfs/lg only reach getAccountsFromMatch (owned by gsiMocks) after an early steam32Id guard, so we cover that collision-safe branch via a parametrized loop. --- .../accountsCommands.integration.test.ts | 61 +++++++++++++++++++ .../src/twitch/lib/__tests__/setupMocks.ts | 5 ++ 2 files changed, 66 insertions(+) create mode 100644 packages/dota/src/twitch/lib/__tests__/accountsCommands.integration.test.ts 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__/setupMocks.ts b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts index 29d385146..34ce11e50 100644 --- a/packages/dota/src/twitch/lib/__tests__/setupMocks.ts +++ b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts @@ -349,6 +349,11 @@ 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. From 63f64e52def2eef3b1a94e3ed8cf1bb3173be10c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 02:48:54 +0000 Subject: [PATCH 23/23] build(deps): bump streamlink in /packages/clip-processor-py Bumps [streamlink](https://github.com/streamlink/streamlink) from 6.5.0 to 8.4.0. - [Release notes](https://github.com/streamlink/streamlink/releases) - [Changelog](https://github.com/streamlink/streamlink/blob/master/CHANGELOG.md) - [Commits](https://github.com/streamlink/streamlink/compare/6.5.0...8.4.0) --- updated-dependencies: - dependency-name: streamlink dependency-version: 8.4.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- packages/clip-processor-py/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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