diff --git a/.doc_surface_floor b/.doc_surface_floor index a2fe2bff..997b0a01 100644 --- a/.doc_surface_floor +++ b/.doc_surface_floor @@ -1 +1 @@ -64.7 +100.0 diff --git a/.github/workflows/doc-audit.yml b/.github/workflows/doc-audit.yml index afa15730..bd96d372 100644 --- a/.github/workflows/doc-audit.yml +++ b/.github/workflows/doc-audit.yml @@ -59,8 +59,13 @@ jobs: # deleted livewire member stops resolving instead of staying excused by a stale # committed file. Mirrors run-ci.sh's SURFACE-NATIVE gate (DOC-AUDIT deps on it). - name: Regenerate the native-only doc-audit sidecar + # `python` (the setup-python shim), not bare `python3` — see the PACKAGE-SMOKE + # note in multi-os.yml. This job is ubuntu-only, where setup-python front-loads + # its dir so both names currently resolve to the toolcache; using the shim keeps + # that true if this workflow ever gains a macOS runner, where bare `python3` + # resolves to the framework Python instead. run: | - python3 signalwire-python/scripts/emit_surface_native.py \ + python signalwire-python/scripts/emit_surface_native.py \ --out signalwire-python/port_surface_native.json - name: Run audit_docs.py against the Python surface @@ -68,12 +73,22 @@ jobs: # surface oracle by design, but livewire/ docs are in this perimeter, so its # real members only resolve via the sidecar. See scripts/emit_surface_native.py. run: | - python3 porting-sdk/scripts/audit_docs.py \ + python porting-sdk/scripts/audit_docs.py \ --root signalwire-python \ --surface porting-sdk/python_surface.json \ --ignore signalwire-python/DOC_AUDIT_IGNORE.md \ --native-names signalwire-python/port_surface_native.json + # The DRIFT half of the regenerate/check pair, mirroring run-ci.sh's + # SURFACE-NATIVE-FRESH. Runs AFTER the audit has consumed the fresh file. On CI + # this cannot leak into a commit (the checkout is ephemeral), but it is what + # makes a STALE committed sidecar fail loudly here instead of passing unnoticed + # and then drifting further. + - name: Committed sidecar matches the regenerated one (no drift) + run: | + cd signalwire-python + git diff --exit-code -- port_surface_native.json + - name: Summary if: always() run: | diff --git a/.github/workflows/multi-os.yml b/.github/workflows/multi-os.yml index 15d0469e..b59b6309 100644 --- a/.github/workflows/multi-os.yml +++ b/.github/workflows/multi-os.yml @@ -65,4 +65,16 @@ jobs: - name: PACKAGE-SMOKE (build + install + import from the built artifact) shell: bash working-directory: signalwire-python - run: python3 ../porting-sdk/scripts/package_smoke.py --port python --repo . + # `python`, not `python3`: use the SAME name `pip` above pairs with, so this + # step provably runs the interpreter the deps were installed into. (On the + # macOS/arm64 runner BOTH names resolve to the pre-installed framework Python + # — setup-python does not win PATH there — and `pip` is that Python's pip, so + # the whole job is consistently one interpreter. Pinning the name keeps it that + # way if the image's precedence ever changes.) + # + # The nightly failure (run 30238061313, macos-latest) was + # "No module named build" — NOT an interpreter mismatch: `build` was never a + # declared dependency at all, so the gate silently relied on the runner image + # shipping it. Now declared in requirements-dev.txt, which the step above + # installs. package_smoke.py is not at fault; it correctly uses sys.executable. + run: python ../porting-sdk/scripts/package_smoke.py --port python --repo . diff --git a/docs/api_reference.md b/docs/api_reference.md index 5ac7d775..00aa85a7 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -2331,32 +2331,6 @@ data_map.webhook( ) ``` -##### `body(data: Dict[str, Any]) -> DataMap` -Set the JSON body for POST/PUT requests. - -**Parameters:** -- `data` (Dict[str, Any]): JSON body data (supports `${variable}` substitution) - -**Usage:** -```python -# Static body with parameter substitution -data_map.body({ - 'query': '${args.search_term}', - 'limit': 5, - 'filters': { - 'category': '${args.category}', - 'active': True - } -}) - -# Body with call-related data (NOT sensitive info) -data_map.body({ - 'customer_id': '${global_data.customer_id}', - 'request_id': '${meta_data.call_id}', - 'search': '${args.query}' -}) -``` - ##### `params(data: Dict[str, Any]) -> DataMap` Set URL query parameters. @@ -2624,7 +2598,7 @@ search_tool = (DataMap('search_knowledge') 'https://api.company.com/search', headers={'Authorization': 'Bearer TOKEN'} ) - .body({ + .params({ 'query': '${args.query}', 'category': '${args.category}', 'limit': 5 @@ -2695,7 +2669,7 @@ agent.register_swaig_function(swaig_function) The SDK provides helper functions for common DataMap patterns: -##### `create_simple_api_tool(name: str, url: str, response_template: str, parameters: Optional[Dict[str, Dict]] = None, method: str = "GET", headers: Optional[Dict[str, str]] = None, body: Optional[Dict[str, Any]] = None, error_keys: Optional[List[str]] = None) -> DataMap` +##### `create_simple_api_tool(name: str, url: str, response_template: str, parameters: Optional[Dict[str, Dict]] = None, method: str = "GET", headers: Optional[Dict[str, str]] = None, error_keys: Optional[List[str]] = None) -> DataMap` Create a simple API integration tool. @@ -2706,7 +2680,6 @@ Create a simple API integration tool. - `parameters` (Optional[Dict[str, Dict]]): Parameter definitions - `method` (str): HTTP method (default: "GET") - `headers` (Optional[Dict[str, str]]): HTTP headers -- `body` (Optional[Dict[str, Any]]): Request body - `error_keys` (Optional[List[str]]): Error field names **Usage:** diff --git a/docs/configuration.md b/docs/configuration.md index 0340b7ae..cf094d3b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -193,6 +193,17 @@ All services share the same security configuration options: } ``` +### TLS misconfiguration fails hard + +When SSL is enabled (`ssl_enabled: true` / `SWML_SSL_ENABLED=true`) but the +certificate or key is missing or unreadable, the server **refuses to start** and +raises a `RuntimeError`. It does not fall back to plain HTTP. + +A silent downgrade would give an operator who asked for encryption a cleartext +listener — carrying, among other things, the Basic-auth credentials — with no +error and nothing in the logs to notice. If you want plain HTTP, disable SSL +explicitly. + ## Migration Guide ### From Environment Variables Only diff --git a/eng/validate_schema.py b/eng/validate_schema.py index 6c878011..21003ece 100755 --- a/eng/validate_schema.py +++ b/eng/validate_schema.py @@ -6,29 +6,28 @@ Licensed under the MIT License. See LICENSE file in the project root for full license information. -""" -""" Validate a JSON file against a JSON Schema. Usage: python validate_schema.py - + Example: python validate_schema.py schema.json steps3.json """ +import argparse import json import sys -import argparse from pathlib import Path -from jsonschema import validate, ValidationError, Draft7Validator + +from jsonschema import Draft7Validator, ValidationError, validate def load_json_file(filepath): """Load and parse a JSON file.""" try: - with open(filepath, 'r') as f: + with Path(filepath).open() as f: return json.load(f) except FileNotFoundError: print(f"❌ Error: File '{filepath}' not found") @@ -43,33 +42,33 @@ def validate_json(schema_file, json_file, verbose=False): # Load files print(f"Loading schema from: {schema_file}") schema = load_json_file(schema_file) - + print(f"Loading JSON from: {json_file}") data = load_json_file(json_file) - + # Try to validate try: validate(instance=data, schema=schema) print("\n✅ Validation PASSED!") return True - + except ValidationError as e: - print(f"\n❌ Validation FAILED!") + print("\n❌ Validation FAILED!") print(f"\nError: {e.message}") - + # Show the path where the error occurred if e.path: path_str = " -> ".join(str(p) for p in e.path) print(f"Location: {path_str}") - + # Show the failing value if verbose if verbose and e.instance is not None: print(f"\nFailing value: {json.dumps(e.instance, indent=2)[:200]}...") - + # Show all errors if there are multiple validator = Draft7Validator(schema) errors = list(validator.iter_errors(data)) - + if len(errors) > 1: print(f"\nFound {len(errors)} validation errors:") for i, error in enumerate(errors, 1): @@ -77,15 +76,17 @@ def validate_json(schema_file, json_file, verbose=False): if error.path: path_str = " -> ".join(str(p) for p in error.path) print(f" Location: {path_str}") - + # Show schema constraint that failed if verbose and error.validator: print(f" Failed constraint: {error.validator}") if error.validator_value is not None: - print(f" Expected: {json.dumps(error.validator_value, indent=2)[:100]}...") - + print( + f" Expected: {json.dumps(error.validator_value, indent=2)[:100]}..." + ) + return False - + except Exception as e: print(f"\n❌ Unexpected error: {e}") return False @@ -93,7 +94,7 @@ def validate_json(schema_file, json_file, verbose=False): def main(): parser = argparse.ArgumentParser( - description='Validate a JSON file against a JSON Schema', + description="Validate a JSON file against a JSON Schema", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: @@ -105,79 +106,83 @@ def main(): # Validate AI config extracted from SWML python validate_schema.py --extract-ai schema.json steps3.json - """ + """, ) - - parser.add_argument('schema', help='Path to the JSON Schema file') - parser.add_argument('json_file', help='Path to the JSON file to validate') - parser.add_argument('-v', '--verbose', action='store_true', - help='Show detailed error information') - parser.add_argument('--extract-ai', action='store_true', - help='Extract and validate just the AI config from SWML format') - + + parser.add_argument("schema", help="Path to the JSON Schema file") + parser.add_argument("json_file", help="Path to the JSON file to validate") + parser.add_argument( + "-v", "--verbose", action="store_true", help="Show detailed error information" + ) + parser.add_argument( + "--extract-ai", + action="store_true", + help="Extract and validate just the AI config from SWML format", + ) + args = parser.parse_args() - + # Check if files exist if not Path(args.schema).exists(): print(f"❌ Error: Schema file '{args.schema}' not found") sys.exit(1) - + if not Path(args.json_file).exists(): print(f"❌ Error: JSON file '{args.json_file}' not found") sys.exit(1) - + # Special handling for AI config extraction if args.extract_ai: print("Extracting AI config from SWML format...") data = load_json_file(args.json_file) schema = load_json_file(args.schema) - + # Extract AI config ai_config = None - if 'sections' in data and 'main' in data.get('sections', {}): - main_section = data.get('sections', {}).get('main', []) + if "sections" in data and "main" in data.get("sections", {}): + main_section = data.get("sections", {}).get("main", []) for item in main_section: - if isinstance(item, dict) and 'ai' in item: - ai_config = item['ai'] + if isinstance(item, dict) and "ai" in item: + ai_config = item["ai"] break - + if not ai_config: print("❌ Error: No AI config found in SWML format") sys.exit(1) - + # Extract AI schema ai_schema = None - if '$defs' in schema and 'AI' in schema['$defs']: - ai_def = schema['$defs']['AI'] - if 'properties' in ai_def and 'ai' in ai_def['properties']: - ai_schema = ai_def['properties']['ai'].copy() + if "$defs" in schema and "AI" in schema["$defs"]: + ai_def = schema["$defs"]["AI"] + if "properties" in ai_def and "ai" in ai_def["properties"]: + ai_schema = ai_def["properties"]["ai"].copy() # Create a new schema with only necessary definitions ai_schema_full = { "$schema": "http://json-schema.org/draft-07/schema#", **ai_schema, - "$defs": schema['$defs'] + "$defs": schema["$defs"], } - + if not ai_schema: print("❌ Error: Could not extract AI schema") sys.exit(1) - + # Validate directly without writing temp files try: validate(instance=ai_config, schema=ai_schema_full) print("\n✅ AI config validation PASSED!") success = True except ValidationError as e: - print(f"\n❌ AI config validation FAILED!") + print("\n❌ AI config validation FAILED!") print(f"\nFirst error: {e.message}") if e.path: path_str = " -> ".join(str(p) for p in e.path) print(f"Location: {path_str}") - + # Show all errors validator = Draft7Validator(ai_schema_full) errors = list(validator.iter_errors(ai_config)) - + if len(errors) > 1: print(f"\nFound {len(errors)} validation errors in total:") for i, error in enumerate(errors, 1): @@ -187,14 +192,14 @@ def main(): print(f" Location: {path_str}") if args.verbose and error.validator: print(f" Failed constraint: {error.validator}") - + success = False else: # Normal validation success = validate_json(args.schema, args.json_file, args.verbose) - + sys.exit(0 if success else 1) -if __name__ == '__main__': - main() \ No newline at end of file +if __name__ == "__main__": + main() diff --git a/examples/data_map_demo.py b/examples/data_map_demo.py index e4bcf04f..8cb82aa7 100644 --- a/examples/data_map_demo.py +++ b/examples/data_map_demo.py @@ -115,7 +115,7 @@ def setup(self): "Content-Type": "application/json", }, ) - .body({"query": "${query}", "limit": "${limit}"}) + .params({"query": "${query}", "limit": "${limit}"}) .foreach( { "input_key": "${response.results}", @@ -271,7 +271,7 @@ def print_data_map_examples(): "https://api.docs.com/search", headers={"Authorization": "Bearer TOKEN"}, ) - .body({"query": "${query}", "limit": 3}) + .params({"query": "${query}", "limit": 3}) .foreach( { "input_key": "${response.results}", diff --git a/pyproject.toml b/pyproject.toml index 145511ee..2fc3021a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -183,8 +183,39 @@ preview = false select = ["E4", "E7", "E9", "F", "B", "S", "C4", "PERF", "SIM", "PTH", "RET", "RUF", "UP"] [tool.ruff.lint.per-file-ignores] -# Tests are built on assert; bandit's S101 is noise there. -"tests/**" = ["S101"] +# tests/ is held to the SAME ruleset as the SDK (owner-ruled 2026-07-29), with the +# same four test-idiomatic carve-outs already granted to examples/** plus S101: +# S101 — the whole point of a test is `assert`. +# E402 — tests/conftest.py MUST insert the project root on sys.path BEFORE importing +# signalwire.*, so imports-not-at-top is structurally forced, not sloppiness. +# S104 — binding 0.0.0.0 is how a server test says "listen on all interfaces". +# S105/S106 — literal "password"/"token" values ARE the fixture; they are not secrets. +# Everything else stays enforced, so tests/ is burnable to zero and STAYS zero: +# F401 unused imports, F841 unused variables, SIM/RET/PERF/C4/PTH/UP/RUF/B and every +# other bandit rule all still red the gate. +"tests/**" = ["S101", "E402", "S104", "S105", "S106"] +# _resolve_type() must keep accepting the LEGACY `typing.Optional[X]` spelling as +# well as PEP-604 `X | None`, and this file is where that contract is proven. Two +# tests (test_optional_str / test_optional_int) pass `Optional[str]`/`Optional[int]` +# as DATA to the function under test; the modern `X | None` spelling is already +# covered separately in the same file (the handler-signature tests). UP045 would +# rewrite the legacy cases into duplicates of the modern ones, deleting the only +# coverage of the legacy path. The annotations in this file that are actually +# annotations are already PEP-604. +"tests/unit/core/agent/tools/test_type_inference.py" = ["UP045"] +# These three test files re-invoke the CURRENT interpreter to get a clean +# subprocess: `sys.executable` (an absolute path, never PATH-resolved) plus a +# fixed list-form arg vector, with the default shell=False — so there is no +# shell to interpolate into. The only non-literal arguments are values the test +# itself chose (an agent file path under tests/, a locally-picked free port), +# never untrusted or network input. A subprocess is structurally required here: +# import-time weight and CLI behavior cannot be measured inside the pytest +# process that has already imported everything. Same rationale as +# cli/dokku.py below; scoped per-file rather than tests/** so any NEW +# subprocess call in the suite still reds the gate. +"tests/test_examples.py" = ["S603"] +"tests/unit/test_import_time.py" = ["S603"] +"tests/unit/relay/conftest.py" = ["S603"] # sw-agent-dokku is a developer-run deploy CLI that shells out to git/ssh with # fixed list-form arg vectors (shell=False, so no shell interpolation). The # dynamic parts (app name, dokku host, config vars) are the operator's own CLI @@ -247,10 +278,9 @@ ignore_missing_imports = true # Response/dict, generated payload types where they exist) — never papered with # bare `Any`. `strict` subsumes check_untyped_defs / warn_unused_ignores / etc. strict = true -# `# type: ignore` is reserved for genuinely-unfixable third-party-stub gaps (e.g. -# flask/flask-limiter optional extras with no stubs) and the documented -# _HostTyped/AgentBase TYPE_CHECKING-vs-runtime split; each must carry an error -# code + reason. warn_unused_ignores (via strict) keeps them honest. +# `# type: ignore` is reserved for genuinely-unfixable third-party-stub gaps and +# the documented _HostTyped/AgentBase TYPE_CHECKING-vs-runtime split; each must +# carry an error code + reason. warn_unused_ignores (via strict) keeps them honest. show_error_codes = true # numpy (a search/relay-extra runtime dep we do NOT type — same class as nltk / diff --git a/requirements-dev.txt b/requirements-dev.txt index 8ff6e385..7e6824f3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -17,6 +17,12 @@ factory-boy>=3.3.0 # For test data factories faker>=19.0.0 # For generating fake data httpx>=0.24.0 # For async HTTP testing aiofiles>=23.0.0 # For async file operations in tests +build>=1.0.0 # PACKAGE-SMOKE gate runs `python -m build --wheel` (see + # porting-sdk/scripts/package_smoke.py plan_python). It was + # never declared, so the gate depended on the runner image + # happening to ship it — and failed on macOS/Windows, where it + # does not (AGENT_RULES §7: a tool a gate needs is DECLARED, + # not assumed present). # Optional-feature deps required by tests under tests/unit/search/. # These mirror the [search-queryonly] extra in pyproject.toml so the @@ -27,4 +33,32 @@ nltk>=3.8 numpy>=1.24.0 scikit-learn>=1.3.0 sentence-transformers>=2.2.0 -mypy>=1.8 +mypy==2.3.0 # TYPECHECK gate in scripts/run-ci.sh; PINNED exact for the + # same reason as ruff above — an open floor lets CI install a + # NEWER mypy than the dev has, and mypy adds checks and + # narrows inference between releases, so the gate reds on + # code that never changed and no local run reproduces it. + # Bump deliberately, with the resulting fixes in the same + # commit. + +# Type STUBS. A stub package is a real dependency of the TYPECHECK gate: its +# PRESENCE (not just its version) changes mypy's verdict on unchanged source, so an +# undeclared stub is the same defect class as an unpinned tool — with a nastier +# signature, because it is invisible to a version check. +# +# Measured 2026-08-04, the reason this pin exists: every repo on a dev box tends to +# resolve ONE shared venv, so a stub declared by a NEIGHBOURING repo silently lands +# on this repo's path. types-PyYAML is declared in api-reference-specs' +# requirements-dev.txt and was in NO signalwire manifest. With it importable, mypy +# reported 3 `redundant-cast` errors at pom.py:519 and swml_renderer.py:146,197; +# without it — which is what CI actually had — mypy reported "Success: no issues +# found in 362 source files". Local and CI were being decided by different type +# information, and "fixing" the local error by deleting those casts would have +# BROKEN CI, where yaml.dump() is untyped Any and the casts were load-bearing. +# +# Declaring it resolves the split in the direction that types MORE: with the stub, +# yaml.dump(stream=None) is genuinely `str` (verified: reveal_type -> "str"), so the +# three casts are truly redundant and were removed in the same commit. Pinned exact +# like the tools above — typeshed revises stubs continuously, and a stub revision +# that changes an overload changes this gate's verdict on code that never changed. +types-PyYAML==6.0.12.20260724 diff --git a/scripts/assert_tool_pins.py b/scripts/assert_tool_pins.py new file mode 100644 index 00000000..13b611ba --- /dev/null +++ b/scripts/assert_tool_pins.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""TOOL-PINS gate — the linters/typecheckers that decide gate verdicts are the +PINNED versions, and the type stubs they read are exactly the DECLARED ones — +not whatever happened to be installed. + +WHY THIS GATE EXISTS +-------------------- +ruff and mypy both change their findings between releases: ruff adds rules and +adjusts format heuristics, mypy adds checks and narrows inference. If the version +that runs is not the version the repo declares, the gate's verdict becomes a +function of WHEN the environment was provisioned rather than of the source — the +classic green-locally/red-in-CI split, where CI installs fresh (newest allowed) and +a contributor runs whatever they installed months ago. No local run reproduces the +CI failure, because the difference is not in the code. + +requirements-dev.txt pins both EXACT. Pinning the manifest is necessary but NOT +sufficient: an environment provisioned before a pin was tightened keeps its old +version indefinitely (pip does not re-resolve an already-satisfied requirement), +so the pin can be right in the file and violated in the interpreter that actually +runs the gates. This gate compares what is IMPORTABLE against what is DECLARED, +reading the expected versions out of requirements-dev.txt so there is exactly one +source of truth and the two cannot drift apart. + +Measured when this gate was added: requirements-dev.txt said `mypy>=1.8` (an open +floor), and the interpreter running the gates had mypy 1.18.2 — a full major +version behind what a fresh CI install would resolve. TYPECHECK's verdict here and +in CI were being produced by different type checkers. + +TYPE STUBS (added 2026-08-04) — the same defect INVERTED. A version check cannot see +a package that is simply ABSENT on one side, and a type stub's mere presence moves +TYPECHECK's verdict: with the stub mypy resolves real signatures, without it +`ignore_missing_imports` makes those calls `Any`. Because every repo on a dev box +tends to resolve ONE shared venv, a stub declared by a NEIGHBOURING repo silently +lands on this repo's path. Measured: types-PyYAML was declared only in +api-reference-specs' requirements-dev.txt; with it importable mypy reported 3 +`redundant-cast` errors, and without it — which is what CI had — mypy reported +"Success: no issues found in 362 source files". Local and CI were type-checking the +same source against different type information, and the local error invited deleting +casts that were load-bearing in CI. So the stub set is now pinned in BOTH directions: +importable-but-undeclared and declared-but-absent are each a failure. + +Set SW_ALLOW_TOOL_VERSION_DRIFT=1 to downgrade a mismatch to a warning, for a +deliberate bump-and-fix run only (then update requirements-dev.txt and land the +resulting fixes in the same commit). +""" + +from __future__ import annotations + +import os +import re +import sys +from importlib.metadata import ( + PackageNotFoundError, + distributions, + version as dist_version, +) +from pathlib import Path + +# Tools whose version changes a gate verdict. Keyed by the distribution name in +# requirements-dev.txt; the value is the gate(s) it decides, for the error message. +PINNED_TOOLS = { + "ruff": "FMT / LINT / EXAMPLES-* / REPO-*", + "mypy": "TYPECHECK", +} + +# Distributions whose mere PRESENCE changes TYPECHECK's verdict. A type-stub package +# (PEP 561 `types-*` / `*-stubs`) supplies type information for a third-party import; +# with it, mypy resolves real signatures, and without it `ignore_missing_imports` +# makes those calls `Any`. Same source, two verdicts — and unlike a version skew it is +# invisible to a version check, because the package is simply absent on one side. +# +# This box resolves ONE SHARED venv across every sibling repo, so a stub declared by a +# NEIGHBOURING repo lands on this repo's path. Measured 2026-08-04: types-PyYAML was +# declared only in api-reference-specs' requirements-dev.txt, and its presence made +# mypy report 3 `redundant-cast` errors that CI (which installs only this repo's +# manifest) did not have. Deleting those casts to satisfy the local error would have +# broken CI, where yaml.dump() is Any and the casts were load-bearing. +# +# So: any stub importable by the gate interpreter must be DECLARED here. Declared but +# absent is caught by the same pass — an environment that lacks a stub the manifest +# requires produces CI's verdict for neither side. +STUB_SUFFIX = "-stubs" +STUB_PREFIX = "types-" + +REPO_ROOT = Path(__file__).resolve().parent.parent +REQUIREMENTS = REPO_ROOT / "requirements-dev.txt" + +# `name==1.2.3` with optional surrounding whitespace, ignoring trailing comments. +PIN_RE = re.compile(r"^\s*(?P[A-Za-z0-9._-]+)\s*==\s*(?P[^\s#;]+)") +# Any non-`==` constraint on a tool we require to be pinned exact. +LOOSE_RE = re.compile(r"^\s*(?P[A-Za-z0-9._-]+)\s*(?P[><~!]=?|===)\s*") + + +def declared_pins() -> tuple[dict[str, str], dict[str, str]]: + """Return ({tool: pinned_version}, {tool: offending_line}) from the manifest.""" + pinned: dict[str, str] = {} + loose: dict[str, str] = {} + for raw in REQUIREMENTS.read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0] + if not line.strip(): + continue + m = PIN_RE.match(line) + if m and m.group("name").lower() in PINNED_TOOLS: + pinned[m.group("name").lower()] = m.group("version") + continue + m = LOOSE_RE.match(line) + if m and m.group("name").lower() in PINNED_TOOLS: + loose[m.group("name").lower()] = raw.strip() + return pinned, loose + + +def _is_stub(name: str) -> bool: + """True if the distribution name is a PEP 561 type-stub package.""" + low = name.lower() + return low.startswith(STUB_PREFIX) or low.endswith(STUB_SUFFIX) + + +def declared_stubs() -> dict[str, str | None]: + """Stub distributions named in the manifest -> pinned version (None if loose).""" + found: dict[str, str | None] = {} + for raw in REQUIREMENTS.read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0] + if not line.strip(): + continue + m = PIN_RE.match(line) + if m and _is_stub(m.group("name")): + found[m.group("name").lower()] = m.group("version") + continue + m = LOOSE_RE.match(line) + if m and _is_stub(m.group("name")): + found[m.group("name").lower()] = None + return found + + +def installed_stubs() -> dict[str, str]: + """Stub distributions importable by THIS interpreter -> installed version.""" + found: dict[str, str] = {} + for dist in distributions(): + name = dist.metadata["Name"] + if name and _is_stub(name): + found[name.lower()] = dist.version + return found + + +def installed_version(tool: str) -> str | None: + """The tool's version as installed for THIS interpreter. + + Read from the installed distribution metadata rather than by shelling out to + `python3 -m --version`. Same answer (run-ci invokes the tools through + this interpreter, so its site-packages is what decides), but no subprocess — + which keeps the gate itself clean under the repo's own ruff ruleset (S603 + flags subprocess calls). Removing the rule's premise beats suppressing it. + """ + try: + return dist_version(tool) + except PackageNotFoundError: + return None + + +def main() -> int: + allow_drift = os.environ.get("SW_ALLOW_TOOL_VERSION_DRIFT") == "1" + pinned, loose = declared_pins() + problems: list[str] = [] + + # 1. Every version-sensitive tool must be pinned EXACT in the manifest. An open + # floor is the defect itself, whatever happens to be installed today. + for tool, gates in PINNED_TOOLS.items(): + if tool in pinned: + continue + if tool in loose: + problems.append( + f"{tool} is NOT pinned exact in requirements-dev.txt " + f'(found "{loose[tool]}"). It decides {gates}, so an open ' + f"constraint lets CI run a different version than local. " + f"Use {tool}=={{version}}." + ) + else: + problems.append( + f"{tool} is missing from requirements-dev.txt but decides {gates}; " + f"declare it as {tool}=={{version}}." + ) + + # 2. The interpreter running the gates must actually HAVE the pinned version. + for tool, want in pinned.items(): + have = installed_version(tool) + if have is None: + problems.append( + f"{tool} is pinned to {want} but is not importable by " + f"{sys.executable} — the gate it decides ({PINNED_TOOLS[tool]}) " + f"cannot run. Install it: pip install {tool}=={want}" + ) + elif have != want: + problems.append( + f"{tool} is {have}, but requirements-dev.txt pins {want}. " + f"{PINNED_TOOLS[tool]} would be decided by a different version " + f"than CI uses. Fix: pip install {tool}=={want}" + ) + + # 3. Type stubs — presence, not just version, decides TYPECHECK. Both directions + # are a local≠CI split, so both fail: + # * importable but UNDECLARED — a neighbouring repo's stub leaked onto this + # repo's path via the shared venv; local sees types CI does not have. + # * declared but ABSENT — this environment types less than CI does. + want_stubs = declared_stubs() + have_stubs = installed_stubs() + + for name, ver in sorted(have_stubs.items()): + if name not in want_stubs: + problems.append( + f"{name}=={ver} is importable by {sys.executable} but is NOT declared " + f"in requirements-dev.txt. A type stub's PRESENCE changes TYPECHECK's " + f"verdict on unchanged source, so this interpreter and CI (which " + f"installs only this manifest) are running different type checks. " + f"Either declare it as {name}=={ver} — and land any resulting source " + f"changes in the same commit — or uninstall it." + ) + + for name, want in sorted(want_stubs.items()): + have = have_stubs.get(name) + if have is None: + problems.append( + f"{name} is declared in requirements-dev.txt but is not importable by " + f"{sys.executable}. TYPECHECK here sees LESS type information than CI " + f"does, so it can pass on code CI rejects. Install it: " + f"pip install -r requirements-dev.txt" + ) + elif want is not None and have != want: + problems.append( + f"{name} is {have}, but requirements-dev.txt pins {want}. Typeshed " + f"revises stubs continuously and an overload change moves TYPECHECK's " + f"verdict. Fix: pip install {name}=={want}" + ) + elif want is None: + problems.append( + f"{name} is declared without an exact pin. A stub revision changes " + f"TYPECHECK's verdict on code that never changed — pin it exact " + f"({name}=={have})." + ) + + if not problems: + names = ", ".join(f"{t}=={v}" for t, v in sorted(pinned.items())) + stub_note = ( + f"; stubs: {', '.join(sorted(have_stubs))}" if have_stubs else "; no stubs" + ) + print(f"[tool-pins] pinned and installed as declared: {names}{stub_note}") + return 0 + + label = "WARNING" if allow_drift else "FAIL" + for p in problems: + print(f"[tool-pins] {label}: {p}", file=sys.stderr) + if allow_drift: + print( + "[tool-pins] SW_ALLOW_TOOL_VERSION_DRIFT=1 — not failing.", file=sys.stderr + ) + return 0 + print( + "[tool-pins] A linter/typechecker version that differs between local and CI " + "makes a gate red on code that never changed. Set " + "SW_ALLOW_TOOL_VERSION_DRIFT=1 only for a deliberate bump run.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/emit_surface_native.py b/scripts/emit_surface_native.py index 771b0bcd..c7529053 100644 --- a/scripts/emit_surface_native.py +++ b/scripts/emit_surface_native.py @@ -60,7 +60,7 @@ def main(argv: list[str]) -> int: psdk = _resolve_porting_sdk() sys.path.insert(0, str(psdk / "scripts")) - import enumerate_python as ep # noqa: E402 (path set above) + import enumerate_python as ep # ``enumerate_module`` short-circuits on ``_module_excluded`` and returns an empty # entry — which is the whole point of the oracle exclusion and exactly what we need diff --git a/scripts/run-ci.sh b/scripts/run-ci.sh index 007c3fcb..eb55ad4c 100755 --- a/scripts/run-ci.sh +++ b/scripts/run-ci.sh @@ -46,6 +46,14 @@ PORT_NAME="signalwire-python" # reads pyproject's per-file-ignores for examples/**, so the SDK ruleset stays intact. EXAMPLE_DIRS=("$PORT_ROOT/examples" "$PORT_ROOT/rest/examples" "$PORT_ROOT/relay/examples") +# Non-shipped python: the test suite, the dev/CI scripts, and eng/. Covered by the +# REPO-LINT / REPO-FMT gates below. Until 2026-07-29 these three dirs were linted and +# format-checked by NOTHING — FMT/LINT target only signalwire/, EXAMPLES-* only the +# example dirs — so 1291 findings had accumulated unseen (and an unused `import socket` +# added that same day went uncaught). ruff reads pyproject's tests/** per-file-ignores +# (S101/E402/S104/S105/S106), so the SDK ruleset otherwise applies in full. +REPO_DIRS=("$PORT_ROOT/tests" "$PORT_ROOT/scripts" "$PORT_ROOT/eng") + resolve_porting_sdk() { if [ -n "${PORTING_SDK:-}" ] && [ -d "$PORTING_SDK/scripts" ]; then echo "$PORTING_SDK" @@ -107,6 +115,19 @@ fmt_gate() { fi } +# REPO-FMT — ruff format over tests/, scripts/, eng/. LOCAL applies; CI --check. +repo_fmt_gate() { + if [ -n "${CI:-}" ]; then + python3 -m ruff format --check "${REPO_DIRS[@]}" + else + python3 -m ruff format "${REPO_DIRS[@]}" >/dev/null + if ! (cd "$PORT_ROOT" && git diff --quiet 2>/dev/null); then + echo " (REPO-FMT auto-applied formatting to your working tree — review & stage)" + fi + python3 -m ruff format --check "${REPO_DIRS[@]}" + fi +} + # EXAMPLES-FMT — ruff format over the shipped example dirs. LOCAL applies; CI --check. examples_fmt_gate() { if [ -n "${CI:-}" ]; then @@ -210,8 +231,14 @@ sched_gate SIGNATURES desc="regenerate python_signatures.json (reference oracle) sched_gate DRIFT deps=SIGNATURES desc="python_signatures.json unchanged after regen" \ -- bash -c "cd '$PORTING_SDK_DIR' && git diff --quiet -- python_signatures.json" -sched_gate SEMVER-DIFF deps=SIGNATURES desc="version bump matches surface change vs python_signatures.baseline.json (the reference is not exempt)" \ - -- python3 "$PORTING_SDK_DIR/scripts/semver_diff.py" --port python --repo "$PORT_ROOT" +# WAVE-1: report-only in-wave (owner-FINAL, re-anchor at cut, D5). GATE_ENFORCEMENT_PLAN.md +# D5a defers the version-line decision to the real release — "no bump churn now; +# perl/rust 4.0.0 declarations stay as-is; unified-vs-per-port decided at cut time" — so +# an intentional in-wave breaking change must REPORT rather than block. Eight ports get +# this hold via the SURFACE suite (_surface_commands.py passes semver_report_only=True); +# python and rust schedule SEMVER-DIFF standalone and so must pass the flag here. +sched_gate SEMVER-DIFF deps=SIGNATURES desc="reports (does not block in-wave, D5a) whether the version bump matches the surface change vs python_signatures.baseline.json — the reference is not exempt from the check" \ + -- python3 "$PORTING_SDK_DIR/scripts/semver_diff.py" --port python --repo "$PORT_ROOT" --report-only sched_gate NO-CHEAT desc="audit_no_cheat_tests" \ -- python3 "$PORTING_SDK_DIR/scripts/audit_no_cheat_tests.py" --root "$PORT_ROOT" @@ -245,6 +272,23 @@ sched_gate EXAMPLES-LINT desc="ruff check zero findings over examples/ rest/exam sched_gate EXAMPLES-FMT desc="ruff format over the example dirs (local: apply; CI: --check)" \ --fn examples_fmt_gate +# REPO-LINT / REPO-FMT — tests/, scripts/ and eng/ held to the SDK's own ruleset. Wired +# 2026-07-29 once the burn reached ZERO (1291 -> 0, commit 4dd2897): burn to zero BEFORE +# wire, so the gate never lands red. Cheap static checks, no build and no mock, so they +# belong in the per-PR cheap wave next to EXAMPLES-*. +sched_gate REPO-LINT desc="ruff check zero findings over tests/ scripts/ eng/" \ + -- python3 -m ruff check "${REPO_DIRS[@]}" + +sched_gate REPO-FMT desc="ruff format over tests/ scripts/ eng/ (local: apply; CI: --check)" \ + --fn repo_fmt_gate + +# TOOL-PINS — the ruff/mypy that decide the gates above are the versions +# requirements-dev.txt declares. A linter version that differs between local and CI +# makes a gate red on code that never changed, and no local run reproduces it. Pure +# static check (reads the manifest, asks the interpreter its versions) → cheap wave. +sched_gate TOOL-PINS desc="ruff/mypy pinned exact in requirements-dev.txt AND installed at that version" \ + -- python3 "$PORT_ROOT/scripts/assert_tool_pins.py" + sched_gate TYPECHECK desc="mypy zero findings" \ -- python3 -m mypy --config-file "$PORT_ROOT/pyproject.toml" @@ -326,6 +370,24 @@ sched_gate DOC-AUDIT deps=SURFACE-NATIVE desc="audit_docs vs python_surface.json --ignore "$PORT_ROOT/DOC_AUDIT_IGNORE.md" \ --native-names "$PORT_ROOT/port_surface_native.json" +# SURFACE-NATIVE-FRESH — the committed sidecar must already BE what SURFACE-NATIVE +# regenerates. This is the DRIFT half of the SIGNATURES/DRIFT pair above: a gate +# that rewrites a COMMITTED artifact in place needs something that fails when the +# rewrite changed it, or the regeneration silently leaks into the working tree and +# from there into a commit. Proven live: adding a native-only member to +# signalwire/livewire/ made SURFACE-NATIVE rewrite the committed sidecar (42 -> 43 +# members) and every gate still passed — only `git status` showed it, and a lane +# reading console output alone would have committed the polluted oracle. +# +# It runs AFTER DOC-AUDIT on purpose: DOC-AUDIT consumes the freshly-regenerated file +# (deps=SURFACE-NATIVE), so restoring or diffing it any earlier would either take the +# fresh bytes away from its consumer or diff a file nothing had written yet. Checking +# rather than restoring is deliberate — the sidecar is a DOC-AUDIT INPUT that must +# exist at its real path, so the fix is "fail when it drifted", not "regenerate into +# a scratch copy". The remedy when this fails is to commit the regenerated sidecar. +sched_gate SURFACE-NATIVE-FRESH deps=DOC-AUDIT desc="committed port_surface_native.json is what emit_surface_native regenerates (no in-tree drift)" \ + -- bash -c "cd '$PORT_ROOT' && git diff --quiet -- port_surface_native.json" + # DOC-WIRE (§A1) — the documented REST fixtures are wire-clean against the spec # (strict-flag mock journals wire_violations; runner replays the doc calls). Cheap. sched_gate DOC-WIRE desc="documented REST doc fixtures put the spec wire shape on the wire (areacode/params:{text})" \ @@ -365,6 +427,26 @@ sched_gate EXAMPLES-RUN tier=nightly defer=1 desc="shipped examples load/start a sched_gate WAIT-LIVENESS tier=nightly defer=1 desc="wait() liveness corpus runs on the reference + yields the golden classification" \ -- python3 "$PORTING_SDK_DIR/scripts/diff_port_wait_liveness.py" --show-oracle --python-sdk "$PORT_ROOT" +# ---- Security-property + suppression gates ---------------------------------- +# These three behavioural rules and the LEDGER suite were DEFINED for python by +# porting-sdk and never scheduled here, so they had never run against the +# reference. All four already pass; wiring them adds coverage, not a red. +# Blocking tier, not nightly: measured at 0s, 0s, 0s and 2s respectively. +sched_gate CA-VAR desc="REST + RELAY honour the custom-CA env vars" \ + -- python3 "$PORTING_SDK_DIR/scripts/suites/behavioral.py" --port python --repo "$PORT_ROOT" \ + --rules CA-VAR + +sched_gate TLS-VERIFY desc="TLS verification is reachable and not silently disabled" \ + -- python3 "$PORTING_SDK_DIR/scripts/suites/behavioral.py" --port python --repo "$PORT_ROOT" \ + --rules TLS-VERIFY + +sched_gate SECRET-SCRUB desc="credentials do not reach logs (static leg)" \ + -- python3 "$PORTING_SDK_DIR/scripts/suites/behavioral.py" --port python --repo "$PORT_ROOT" \ + --rules SECRET-SCRUB + +sched_gate LEDGER desc="ledger suite (SUPPRESSION-LEDGER/IGNORE-LEDGER-VERIFY)" \ + -- python3 "$PORTING_SDK_DIR/scripts/suites/ledger.py" --port python --repo "$PORT_ROOT" + # ---- Day-one deterministic doc/tree-hygiene gates --------------------------- sched_gate DOC-LINKS desc="every relative markdown link resolves to a tracked file" \ -- python3 "$PORTING_SDK_DIR/scripts/doc_links.py" --port python --repo "$PORT_ROOT" diff --git a/signalwire/signalwire/agent_server.py b/signalwire/signalwire/agent_server.py index 18c47eea..e97530f0 100644 --- a/signalwire/signalwire/agent_server.py +++ b/signalwire/signalwire/agent_server.py @@ -673,14 +673,30 @@ def _run_server(self, host: str | None = None, port: int | None = None) -> None: ssl_key_path = os.environ.get("SWML_SSL_KEY_PATH") domain = os.environ.get("SWML_DOMAIN") - # Validate SSL configuration if enabled + # Validate SSL configuration if enabled. + # + # TLS that cannot be configured is a FATAL misconfiguration, never a + # silent downgrade: the operator asked for encryption, and starting a + # cleartext listener instead would ship their traffic — including the + # project id and API token carried in Basic auth — in the clear, with + # no error and no way to notice. Refuse to start. if ssl_enabled: if not ssl_cert_path or not Path(ssl_cert_path).exists(): - self.logger.warning(f"SSL cert not found: {ssl_cert_path}") - ssl_enabled = False - elif not ssl_key_path or not Path(ssl_key_path).exists(): - self.logger.warning(f"SSL key not found: {ssl_key_path}") - ssl_enabled = False + raise RuntimeError( + f"SWML_SSL_ENABLED is set but the SSL certificate is missing: " + f"SWML_SSL_CERT_PATH={ssl_cert_path!r}. Refusing to start a " + f"plaintext listener when TLS was requested — set " + f"SWML_SSL_CERT_PATH to a readable certificate, or unset " + f"SWML_SSL_ENABLED to serve plain HTTP deliberately." + ) + if not ssl_key_path or not Path(ssl_key_path).exists(): + raise RuntimeError( + f"SWML_SSL_ENABLED is set but the SSL private key is missing: " + f"SWML_SSL_KEY_PATH={ssl_key_path!r}. Refusing to start a " + f"plaintext listener when TLS was requested — set " + f"SWML_SSL_KEY_PATH to a readable key, or unset " + f"SWML_SSL_ENABLED to serve plain HTTP deliberately." + ) # Update server info display with correct protocol protocol = "https" if ssl_enabled else "http" diff --git a/signalwire/signalwire/ai_chat/client.py b/signalwire/signalwire/ai_chat/client.py index 6111edcb..42f0afea 100644 --- a/signalwire/signalwire/ai_chat/client.py +++ b/signalwire/signalwire/ai_chat/client.py @@ -107,6 +107,18 @@ class SummaryError(AIChatError): @dataclass class ConversationInfo: + """Result of :meth:`AIChatClient.create_conversation`. + + Attributes: + id: The conversation id, echoed back from the id you supplied — the + service does not mint one, so this always equals the argument. + status: Server-reported lifecycle state, defaulting to ``"created"`` + when the result carries no ``status`` key. + initial_message: The AI's opening turn when ``user_message`` was passed + to ``create_conversation`` (the server answers it immediately); + ``None`` when the conversation was created without a first message. + """ + id: str status: str initial_message: str | None = None @@ -114,6 +126,18 @@ class ConversationInfo: @dataclass class ChatResponse: + """Result of :meth:`AIChatClient.chat` — one AI turn. + + Attributes: + text: The AI's reply, taken from the result's ``response`` field. + Empty string if the service returned no ``response``. + conversation_id: The conversation this turn belongs to, echoed from + the request argument rather than read from the response. + user_event: The service's raw ``user_event`` object for this turn when + present (SWAIG/tool activity and other side-channel data emitted + while the turn ran); ``None`` when the turn produced none. + """ + text: str conversation_id: str user_event: dict[str, Any] | None = None @@ -121,6 +145,15 @@ class ChatResponse: @dataclass class ChatLog: + """Result of :meth:`AIChatClient.log` — a conversation's stored history. + + Attributes: + messages: The ``chat_log`` array — the conversation's messages as raw + dicts in service order. Empty list when the conversation has none. + call_timeline: The ``call_timeline`` array — timeline entries the + service recorded alongside the messages. Empty list when absent. + """ + messages: list[dict[str, Any]] = field(default_factory=list) call_timeline: list[dict[str, Any]] = field(default_factory=list) @@ -199,6 +232,19 @@ async def _ensure_session(self) -> aiohttp.ClientSession: return self._session async def close(self) -> None: + """Close the underlying aiohttp session, if this client owns it. + + Only closes a session the client created itself; a session passed to + the constructor is left alone for its owner to close. Called + automatically on ``__aexit__``. + + The client is NOT permanently dead afterwards: the internal session + reference is cleared, so the next request lazily builds a fresh + session with the same auth, headers and timeout. Closing is therefore + safe to repeat and safe to do between bursts of traffic — what it + costs is the connection pool, not the client. Conversations live on + the server and are unaffected. + """ if self._owns_session and self._session is not None: await self._session.close() self._session = None diff --git a/signalwire/signalwire/cli/core/agent_loader.py b/signalwire/signalwire/cli/core/agent_loader.py index ea9f9618..f3d22372 100644 --- a/signalwire/signalwire/cli/core/agent_loader.py +++ b/signalwire/signalwire/cli/core/agent_loader.py @@ -512,11 +512,34 @@ def _load_service_impl( patches_applied = [] def mock_serve(self: Any, *args: Any, **kwargs: Any) -> Any: + """Stand in for ``SWMLService.serve()`` while ``main()`` is called. + + Temporarily patched over the real ``serve()`` on both + ``SWMLService`` and ``AgentBase`` so that invoking the module's + ``main()`` builds and configures its service without ever + starting a web server. The receiver is appended to + ``captured_services`` so the caller can recover the configured + instance, and all positional/keyword arguments the agent passed + to ``serve()`` (host, port, ...) are ignored. + + Returns: + The service instance it was called on. + """ captured_services.append(self) print(" (Intercepted serve() call, service captured for testing)") return self def mock_run(self: Any, *args: Any, **kwargs: Any) -> Any: + """Stand in for ``SWMLService.run()`` while ``main()`` is called. + + The ``run()`` counterpart of :func:`mock_serve` — patched over + ``run()`` on ``SWMLService`` and ``AgentBase`` so an agent whose + ``main()`` ends in ``agent.run()`` is captured for inspection + instead of blocking in a server loop. Arguments are ignored. + + Returns: + The service instance it was called on. + """ captured_services.append(self) print(" (Intercepted run() call, service captured for testing)") return self diff --git a/signalwire/signalwire/cli/dokku.py b/signalwire/signalwire/cli/dokku.py index f181aad5..680f8af4 100644 --- a/signalwire/signalwire/cli/dokku.py +++ b/signalwire/signalwire/cli/dokku.py @@ -32,6 +32,18 @@ class Colors: + """Raw ANSI SGR escape sequences used by this tool's console output. + + A namespace of constants, never instantiated. ``NC`` ("no color") is the + reset sequence every other constant must be closed with. The codes are + emitted unconditionally — there is no TTY detection or ``NO_COLOR`` + handling — so redirected output contains the escapes verbatim. + + This is ``sw-agent-dokku``'s own copy; ``cli/init_project.py`` defines a + separate ``Colors`` for ``sw-agent-init`` (which has no ``MAGENTA``). The + two are independent, not a shared module. + """ + RED = "\033[0;31m" GREEN = "\033[0;32m" YELLOW = "\033[1;33m" @@ -44,26 +56,78 @@ class Colors: def print_step(msg: str) -> None: + """Print ``msg`` to stdout as a progress step, prefixed with a blue ``==>``. + + Used to announce the action about to be performed; the outcome is then + reported with :func:`print_success`, :func:`print_warning` or + :func:`print_error`. + + Args: + msg: Text to display after the marker. + """ print(f"{Colors.BLUE}==>{Colors.NC} {msg}") def print_success(msg: str) -> None: + """Print ``msg`` to stdout marked with a green check, reporting success. + + Args: + msg: Text to display after the marker. + """ print(f"{Colors.GREEN}✓{Colors.NC} {msg}") def print_warning(msg: str) -> None: + """Print ``msg`` to stdout marked with a yellow ``!``. + + For conditions the user should know about that do not stop the command — + it writes to stdout like the rest and does not change the exit status. + + Args: + msg: Text to display after the marker. + """ print(f"{Colors.YELLOW}!{Colors.NC} {msg}") def print_error(msg: str) -> None: + """Print ``msg`` to stdout marked with a red ``✗``. + + Reports a failure only; it does NOT write to stderr, raise, or exit — the + caller is responsible for returning a non-zero exit code. + + Args: + msg: Text to display after the marker. + """ print(f"{Colors.RED}✗{Colors.NC} {msg}") def print_header(msg: str) -> None: + """Print ``msg`` as a bold cyan section heading, preceded by a blank line. + + Used to separate the phases of a multi-step command; unlike the other + helpers it emits no marker glyph. + + Args: + msg: Heading text. + """ print(f"\n{Colors.BOLD}{Colors.CYAN}{msg}{Colors.NC}") def prompt(question: str, default: str = "") -> str: + """Ask ``question`` on the console and return the user's answer. + + Blocks on stdin. Surrounding whitespace is stripped from the reply. When + ``default`` is non-empty it is shown in brackets and returned if the user + just presses Enter; with no default, an empty string is returned as-is + (the caller must validate it). + + Args: + question: Text shown before the input cursor. + default: Value substituted for an empty reply. + + Returns: + The stripped user input, or ``default``. + """ if default: result = input(f"{question} [{default}]: ").strip() return result if result else default @@ -71,6 +135,20 @@ def prompt(question: str, default: str = "") -> str: def prompt_yes_no(question: str, default: bool = True) -> bool: + """Ask ``question`` as a yes/no question and return the answer. + + Blocks on stdin and displays the default as ``[Y/n]`` or ``[y/N]``. An + empty reply yields ``default``; otherwise only ``y`` and ``yes`` + (case-insensitive) count as yes — every other input, including a typo, + is treated as NO rather than re-prompting. + + Args: + question: Text shown before the input cursor. + default: Answer used when the user just presses Enter. + + Returns: + ``True`` for yes, ``False`` for no. + """ hint = "Y/n" if default else "y/N" result = input(f"{question} [{hint}]: ").strip().lower() if not result: @@ -79,6 +157,22 @@ def prompt_yes_no(question: str, default: bool = True) -> bool: def generate_password(length: int = 32) -> str: + """Generate a random URL-safe secret of exactly ``length`` characters. + + Drawn from :func:`secrets.token_urlsafe`, i.e. the OS cryptographic RNG, + so it is suitable for the credentials this tool sets as Dokku config vars. + ``token_urlsafe(length)`` yields MORE than ``length`` characters + (base64url of ``length`` random bytes), and the result is truncated to + ``length``; the retained entropy is therefore roughly ``6 * length`` bits, + not ``8 * length``. The alphabet is base64url — ``A-Z``, ``a-z``, ``0-9``, + ``-`` and ``_``. + + Args: + length: Number of characters to return. Defaults to 32. + + Returns: + A random string of exactly ``length`` characters. + """ return secrets.token_urlsafe(length)[:length] @@ -1961,7 +2055,11 @@ def _write_file(self, path: str, content: str, executable: bool = False) -> None """Write a file to the project directory.""" file_path = self.project_dir / path file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(content) + # Always UTF-8, never the platform default. Several templates embed + # box-drawing characters (U+2500/U+2550) and arrows, which the Windows + # default codec (cp1252) cannot encode -- writing without an explicit + # encoding raises UnicodeEncodeError there. + file_path.write_text(content, encoding="utf-8") if executable: file_path.chmod(0o755) @@ -2101,7 +2199,7 @@ def cmd_deploy(args: argparse.Namespace) -> int: try: with open( # noqa: PTH123 # tests patch builtins.open while mocking Path; Path.open() would bypass the mock seam - "app.json" + "app.json", encoding="utf-8" ) as f: app_json = json.load(f) app_name = app_json.get("name") @@ -2232,7 +2330,7 @@ def _get_app_name() -> str: try: with open( # noqa: PTH123 # tests patch builtins.open while mocking Path; Path.open() would bypass the mock seam - "app.json" + "app.json", encoding="utf-8" ) as f: # json.load() is typed -> Any; the "name" field is a string # (default "" when absent). Coerce to satisfy the str return. @@ -2248,6 +2346,33 @@ def _get_app_name() -> str: def main() -> int: + """Entry point for the ``sw-agent-dokku`` command. + + Parses ``sys.argv`` and dispatches to one of five subcommands, each of + which drives a Dokku deployment of a SignalWire agent: + + - ``init NAME`` — scaffold a new project directory (Procfile, runtime, + requirements, agent source, ``app.json``), optionally with GitHub Actions + CI/CD (``--cicd``), a static web interface at ``/`` (``--web``), a + preconfigured Dokku ``--host``, an explicit ``--dir``, and ``--force`` to + overwrite an existing directory. + - ``deploy`` — deploy the current directory: create the Dokku app if + needed, point a ``dokku`` git remote at it, and force-push ``HEAD`` to + its ``main``. Requires a ``Procfile`` in the working directory. + - ``logs`` — show the app's logs, with ``--tail`` to follow and ``--num`` + to limit the number of lines. + - ``config show|set|unset [KEY=value ...]`` — read or change the app's + Dokku config variables. + - ``scale [web=2 ...]`` — set process-type scale. + + Every subcommand except ``init`` accepts ``--app``/``-a`` and + ``--host``/``-H``; when omitted these are inferred from ``app.json`` or the + current directory name, falling back to an interactive prompt. + + Returns: + The subcommand's exit code, or ``1`` after printing help when no + subcommand was given. + """ parser = argparse.ArgumentParser( description="SignalWire Agent Dokku Deployment Tool", formatter_class=argparse.RawDescriptionHelpFormatter, diff --git a/signalwire/signalwire/cli/init_project.py b/signalwire/signalwire/cli/init_project.py index e75d2f65..bf9358fe 100644 --- a/signalwire/signalwire/cli/init_project.py +++ b/signalwire/signalwire/cli/init_project.py @@ -36,6 +36,18 @@ # ANSI colors class Colors: + """Raw ANSI SGR escape sequences used by this generator's console output. + + A namespace of constants, never instantiated. ``NC`` ("no color") is the + reset sequence every other constant must be closed with. The codes are + emitted unconditionally — there is no TTY detection or ``NO_COLOR`` + handling — so redirected output contains the escapes verbatim. + + This is ``sw-agent-init``'s own copy; ``cli/dokku.py`` defines a separate + ``Colors`` for ``sw-agent-dokku`` that additionally carries ``MAGENTA``. + The two are independent, not a shared module. + """ + RED = "\033[0;31m" GREEN = "\033[0;32m" YELLOW = "\033[1;33m" @@ -47,18 +59,49 @@ class Colors: def print_step(msg: str) -> None: + """Print ``msg`` to stdout as a progress step, prefixed with a blue ``==>``. + + Announces the scaffolding action about to be performed; the outcome is then + reported with :func:`print_success`, :func:`print_warning` or + :func:`print_error`. + + Args: + msg: Text to display after the marker. + """ print(f"{Colors.BLUE}==>{Colors.NC} {msg}") def print_success(msg: str) -> None: + """Print ``msg`` to stdout marked with a green check, reporting success. + + Args: + msg: Text to display after the marker. + """ print(f"{Colors.GREEN}✓{Colors.NC} {msg}") def print_warning(msg: str) -> None: + """Print ``msg`` to stdout marked with a yellow ``!``. + + For conditions the user should know about that do not stop project + generation — it writes to stdout like the rest and does not change the + exit status. + + Args: + msg: Text to display after the marker. + """ print(f"{Colors.YELLOW}!{Colors.NC} {msg}") def print_error(msg: str) -> None: + """Print ``msg`` to stdout marked with a red ``✗``. + + Reports a failure only; it does NOT write to stderr, raise, or exit — the + caller is responsible for aborting and returning a non-zero exit code. + + Args: + msg: Text to display after the marker. + """ print(f"{Colors.RED}✗{Colors.NC} {msg}") @@ -1915,17 +1958,19 @@ def _generate_aws(self) -> bool: # handler.py handler_code = AWS_HANDLER_TEMPLATE.format(**template_vars) - (self.project_dir / "handler.py").write_text(handler_code) + (self.project_dir / "handler.py").write_text(handler_code, encoding="utf-8") print_success("Created handler.py") # requirements.txt - (self.project_dir / "requirements.txt").write_text(AWS_REQUIREMENTS_TEMPLATE) + (self.project_dir / "requirements.txt").write_text( + AWS_REQUIREMENTS_TEMPLATE, encoding="utf-8" + ) print_success("Created requirements.txt") # deploy.sh deploy_code = AWS_DEPLOY_TEMPLATE.format(**template_vars) deploy_path = self.project_dir / "deploy.sh" - deploy_path.write_text(deploy_code) + deploy_path.write_text(deploy_code, encoding="utf-8") deploy_path.chmod(0o755) print_success("Created deploy.sh") @@ -1933,7 +1978,9 @@ def _generate_aws(self) -> bool: self._create_cloud_env_example("aws") # .gitignore - (self.project_dir / ".gitignore").write_text(TEMPLATE_GITIGNORE) + (self.project_dir / ".gitignore").write_text( + TEMPLATE_GITIGNORE, encoding="utf-8" + ) print_success("Created .gitignore") # README.md @@ -1951,17 +1998,19 @@ def _generate_gcp(self) -> bool: # main.py main_code = GCP_MAIN_TEMPLATE.format(**template_vars) - (self.project_dir / "main.py").write_text(main_code) + (self.project_dir / "main.py").write_text(main_code, encoding="utf-8") print_success("Created main.py") # requirements.txt - (self.project_dir / "requirements.txt").write_text(GCP_REQUIREMENTS_TEMPLATE) + (self.project_dir / "requirements.txt").write_text( + GCP_REQUIREMENTS_TEMPLATE, encoding="utf-8" + ) print_success("Created requirements.txt") # deploy.sh deploy_code = GCP_DEPLOY_TEMPLATE.format(**template_vars) deploy_path = self.project_dir / "deploy.sh" - deploy_path.write_text(deploy_code) + deploy_path.write_text(deploy_code, encoding="utf-8") deploy_path.chmod(0o755) print_success("Created deploy.sh") @@ -1969,7 +2018,9 @@ def _generate_gcp(self) -> bool: self._create_cloud_env_example("gcp") # .gitignore - (self.project_dir / ".gitignore").write_text(TEMPLATE_GITIGNORE) + (self.project_dir / ".gitignore").write_text( + TEMPLATE_GITIGNORE, encoding="utf-8" + ) print_success("Created .gitignore") # README.md @@ -1991,31 +2042,37 @@ def _generate_azure(self) -> bool: # function_app/__init__.py init_code = AZURE_INIT_TEMPLATE.format(**template_vars) - (function_dir / "__init__.py").write_text(init_code) + (function_dir / "__init__.py").write_text(init_code, encoding="utf-8") print_success("Created function_app/__init__.py") # function_app/function.json - (function_dir / "function.json").write_text(AZURE_FUNCTION_JSON_TEMPLATE) + (function_dir / "function.json").write_text( + AZURE_FUNCTION_JSON_TEMPLATE, encoding="utf-8" + ) print_success("Created function_app/function.json") # host.json - (self.project_dir / "host.json").write_text(AZURE_HOST_JSON_TEMPLATE) + (self.project_dir / "host.json").write_text( + AZURE_HOST_JSON_TEMPLATE, encoding="utf-8" + ) print_success("Created host.json") # local.settings.json (self.project_dir / "local.settings.json").write_text( - AZURE_LOCAL_SETTINGS_TEMPLATE + AZURE_LOCAL_SETTINGS_TEMPLATE, encoding="utf-8" ) print_success("Created local.settings.json") # requirements.txt - (self.project_dir / "requirements.txt").write_text(AZURE_REQUIREMENTS_TEMPLATE) + (self.project_dir / "requirements.txt").write_text( + AZURE_REQUIREMENTS_TEMPLATE, encoding="utf-8" + ) print_success("Created requirements.txt") # deploy.sh deploy_code = AZURE_DEPLOY_TEMPLATE.format(**template_vars) deploy_path = self.project_dir / "deploy.sh" - deploy_path.write_text(deploy_code) + deploy_path.write_text(deploy_code, encoding="utf-8") deploy_path.chmod(0o755) print_success("Created deploy.sh") @@ -2023,7 +2080,9 @@ def _generate_azure(self) -> bool: self._create_cloud_env_example("azure") # .gitignore - (self.project_dir / ".gitignore").write_text(TEMPLATE_GITIGNORE) + (self.project_dir / ".gitignore").write_text( + TEMPLATE_GITIGNORE, encoding="utf-8" + ) print_success("Created .gitignore") # README.md @@ -2073,7 +2132,7 @@ def _create_cloud_env_example(self, platform: str) -> None: SWML_BASIC_AUTH_USER=admin SWML_BASIC_AUTH_PASSWORD=your-secure-password """ - (self.project_dir / ".env.example").write_text(env_content) + (self.project_dir / ".env.example").write_text(env_content, encoding="utf-8") print_success("Created .env.example") def _create_cloud_readme(self, platform: str) -> None: @@ -2269,7 +2328,7 @@ def _create_cloud_readme(self, platform: str) -> None: Set your phone number's SWML URL to the endpoint URL shown after deployment. """ - (self.project_dir / "README.md").write_text(readme) + (self.project_dir / "README.md").write_text(readme, encoding="utf-8") print_success("Created README.md") def _create_directories(self) -> None: @@ -2291,24 +2350,26 @@ def _create_agent_files(self) -> None: agents_dir = self.project_dir / "agents" # __init__.py - (agents_dir / "__init__.py").write_text(TEMPLATE_AGENTS_INIT) + (agents_dir / "__init__.py").write_text(TEMPLATE_AGENTS_INIT, encoding="utf-8") print_success("Created agents/__init__.py") # main_agent.py agent_code = get_agent_template( self.config.get("agent_type", "basic"), self.features ) - (agents_dir / "main_agent.py").write_text(agent_code) + (agents_dir / "main_agent.py").write_text(agent_code, encoding="utf-8") print_success("Created agents/main_agent.py") # skills/__init__.py - (self.project_dir / "skills" / "__init__.py").write_text(TEMPLATE_SKILLS_INIT) + (self.project_dir / "skills" / "__init__.py").write_text( + TEMPLATE_SKILLS_INIT, encoding="utf-8" + ) print_success("Created skills/__init__.py") def _create_app_file(self) -> None: """Create main app.py entry point.""" app_code = get_app_template(self.features) - (self.project_dir / "app.py").write_text(app_code) + (self.project_dir / "app.py").write_text(app_code, encoding="utf-8") print_success("Created app.py") def _create_config_files(self) -> None: @@ -2342,43 +2403,49 @@ def _create_config_files(self) -> None: DEBUG_WEBHOOK_LEVEL=1 """ - (self.project_dir / ".env").write_text(env_content) + (self.project_dir / ".env").write_text(env_content, encoding="utf-8") print_success("Created .env") # .env.example - (self.project_dir / ".env.example").write_text(TEMPLATE_ENV_EXAMPLE) + (self.project_dir / ".env.example").write_text( + TEMPLATE_ENV_EXAMPLE, encoding="utf-8" + ) print_success("Created .env.example") # .gitignore - (self.project_dir / ".gitignore").write_text(TEMPLATE_GITIGNORE) + (self.project_dir / ".gitignore").write_text( + TEMPLATE_GITIGNORE, encoding="utf-8" + ) print_success("Created .gitignore") # requirements.txt - (self.project_dir / "requirements.txt").write_text(TEMPLATE_REQUIREMENTS) + (self.project_dir / "requirements.txt").write_text( + TEMPLATE_REQUIREMENTS, encoding="utf-8" + ) print_success("Created requirements.txt") def _create_test_files(self) -> None: """Create test files.""" tests_dir = self.project_dir / "tests" - (tests_dir / "__init__.py").write_text(TEMPLATE_TESTS_INIT) + (tests_dir / "__init__.py").write_text(TEMPLATE_TESTS_INIT, encoding="utf-8") print_success("Created tests/__init__.py") test_code = get_test_template(self.features.get("example_tool", True)) - (tests_dir / "test_agent.py").write_text(test_code) + (tests_dir / "test_agent.py").write_text(test_code, encoding="utf-8") print_success("Created tests/test_agent.py") def _create_web_files(self) -> None: """Create web UI files.""" web_dir = self.project_dir / "web" - (web_dir / "index.html").write_text(get_web_index_template()) + (web_dir / "index.html").write_text(get_web_index_template(), encoding="utf-8") print_success("Created web/index.html") def _create_readme(self) -> None: """Create README.md.""" readme = get_readme_template(self.project_name, self.features) - (self.project_dir / "README.md").write_text(readme) + (self.project_dir / "README.md").write_text(readme, encoding="utf-8") print_success("Created README.md") def _create_virtualenv(self) -> None: diff --git a/signalwire/signalwire/cli/output/swml_dump.py b/signalwire/signalwire/cli/output/swml_dump.py index a89bc3eb..4b0e7982 100644 --- a/signalwire/signalwire/cli/output/swml_dump.py +++ b/signalwire/signalwire/cli/output/swml_dump.py @@ -36,6 +36,15 @@ def setup_output_suppression() -> None: # Capture and suppress print statements def suppressed_print(*args: Any, **kwargs: Any) -> None: + """Replacement for the builtin ``print`` that drops stdout writes. + + Installed as ``builtins.print`` so that agent code loaded for a SWML + dump cannot contaminate stdout — the dumped SWML document must be the + only thing on stdout for the caller to parse. A call that names an + explicit ``file`` other than ``sys.stdout`` (typically ``sys.stderr``) + is forwarded to the saved original ``print``; everything else is + discarded silently. + """ # If file is specified (like stderr), allow it if "file" in kwargs and kwargs["file"] is not sys.stdout: original_print(*args, **kwargs) diff --git a/signalwire/signalwire/cli/simulation/mock_env.py b/signalwire/signalwire/cli/simulation/mock_env.py index 3e0ef705..8008a235 100644 --- a/signalwire/signalwire/cli/simulation/mock_env.py +++ b/signalwire/signalwire/cli/simulation/mock_env.py @@ -24,6 +24,17 @@ def __init__(self, params: dict[str, str] | None = None): self._params = params or {} def get(self, key: str, default: str | None = None) -> str | None: + """Return the query-string value for ``key``, or ``default`` if absent. + + Lookup is exact and case-SENSITIVE, unlike :meth:`MockHeaders.get`. + + Args: + key: Query parameter name, matched exactly as given. + default: Value returned when the parameter was not supplied. + + Returns: + The parameter value, or ``default`` (``None`` unless overridden). + """ return self._params.get(key, default) def __getitem__(self, key: str) -> str: @@ -33,12 +44,27 @@ def __contains__(self, key: str) -> bool: return key in self._params def items(self) -> ItemsView[str, str]: + """Return a view of the ``(name, value)`` pairs of every query parameter. + + Returns: + The underlying dict's ``items()`` view — a live view, not a copy. + """ return self._params.items() def keys(self) -> KeysView[str]: + """Return a view of the query parameter names, in insertion order. + + Returns: + The underlying dict's ``keys()`` view — a live view, not a copy. + """ return self._params.keys() def values(self) -> ValuesView[str]: + """Return a view of the query parameter values. + + Returns: + The underlying dict's ``values()`` view — a live view, not a copy. + """ return self._params.values() @@ -53,6 +79,19 @@ def __init__(self, headers: dict[str, str] | None = None): self._headers[k.lower()] = v def get(self, key: str, default: str | None = None) -> str | None: + """Return the header value for ``key``, or ``default`` if absent. + + The lookup is case-INSENSITIVE: ``key`` is lowercased before matching, + so ``get("Content-Type")`` and ``get("content-type")`` are equivalent. + This mirrors FastAPI/Starlette header semantics. + + Args: + key: Header name in any casing. + default: Value returned when the header was not supplied. + + Returns: + The header value, or ``default`` (``None`` unless overridden). + """ return self._headers.get(key.lower(), default) def __getitem__(self, key: str) -> str: @@ -62,12 +101,36 @@ def __contains__(self, key: str) -> bool: return key.lower() in self._headers def items(self) -> ItemsView[str, str]: + """Return a view of the ``(name, value)`` pairs of every header. + + Names are yielded LOWERCASED — they were normalized on construction, so + the original casing the caller supplied is not preserved. + + Returns: + The underlying dict's ``items()`` view — a live view, not a copy. + """ return self._headers.items() def keys(self) -> KeysView[str]: + """Return a view of the header names, lowercased. + + As with :meth:`items`, the names reflect the normalized (lowercase) + storage rather than the casing originally passed in. + + Returns: + The underlying dict's ``keys()`` view — a live view, not a copy. + """ return self._headers.keys() def values(self) -> ValuesView[str]: + """Return a view of the header values, unmodified. + + Only header NAMES are normalized on construction; values are stored + exactly as supplied. + + Returns: + The underlying dict's ``values()`` view — a live view, not a copy. + """ return self._headers.values() diff --git a/signalwire/signalwire/core/agent/tools/decorator.py b/signalwire/signalwire/core/agent/tools/decorator.py index 4eebd9c6..b56c43bc 100644 --- a/signalwire/signalwire/core/agent/tools/decorator.py +++ b/signalwire/signalwire/core/agent/tools/decorator.py @@ -86,6 +86,39 @@ def lookup_account(self, args, raw_data): """ def inner_decorator(func: _F) -> _F: + """ + Register ``func`` with the registry and return it unchanged. + + Pops ``parameters``, ``description``, ``secure`` (default + True), ``fillers``, ``webhook_url`` and ``required`` out of the + decorator's kwargs; whatever kwargs remain are forwarded to + ``registry.define_tool()`` as extra SWAIG fields. + + Name resolution: the ``name`` given to the decorator, else + ``func.__name__``. + + Schema resolution: if no explicit ``parameters`` were passed, + ``infer_schema(func)`` derives them from the type hints. When + inference reports a typed handler, the inferred parameters + replace the empty dict, the inferred required-list and + docstring summary fill in ``required``/``description`` only + where those were left None, and the registered handler becomes + ``create_typed_handler_wrapper(func, has_raw_data)`` so the + registry can still call it with the (args, raw_data) + convention. If inference declines (old-style ``(args, + raw_data)`` signature, ``*args``/``**kwargs``, or no + annotations), the raw function is registered as-is. + + Description fallback order: explicit ``description`` kwarg, + then ``func.__doc__``, then the literal ``"Function "``. + + Args: + func: The function to register as a SWAIG tool. + + Returns: + ``func`` unmodified — the decorator has a registration side + effect only, so the decorated name stays directly callable. + """ nonlocal name if name is None: name = func.__name__ @@ -188,6 +221,33 @@ def lookup_account(self, args, raw_data): """ def decorator(func: _F) -> _F: + """ + Mark ``func`` as a class-decorated SWAIG tool. + + Unlike the instance decorator, nothing is registered here — + there is no agent instance yet at class-definition time. This + only stamps three marker attributes onto the function, which + ``ToolRegistry.register_class_decorated_tools()`` scans for at + agent construction time and turns into real tool definitions: + + - ``_is_tool``: True + - ``_tool_name``: the ``name`` given to the decorator, else + ``func.__name__`` + - ``_tool_params``: every remaining decorator kwarg + (``description``, ``parameters``, ``secure``, ``fillers``, + …), passed through verbatim + + Because registration is deferred, type inference does NOT run + here: schema inference happens when the registry processes the + marked function. + + Args: + func: The method being decorated. + + Returns: + ``func`` itself, so the method stays a normal callable + attribute of the class. + """ # Mark the function as a tool func._is_tool = True # type: ignore[attr-defined] # dynamic marker attrs read back by ToolRegistry.register_class_decorated_tools func._tool_name = name if name else func.__name__ # type: ignore[attr-defined] diff --git a/signalwire/signalwire/core/agent/tools/type_inference.py b/signalwire/signalwire/core/agent/tools/type_inference.py index 7f928c43..a5c2c4cb 100644 --- a/signalwire/signalwire/core/agent/tools/type_inference.py +++ b/signalwire/signalwire/core/agent/tools/type_inference.py @@ -11,6 +11,7 @@ import inspect import re +import types import typing from typing import Any, get_type_hints from collections.abc import Callable @@ -37,8 +38,17 @@ def _resolve_type(annotation: Any) -> tuple[dict[str, Any], bool]: """ origin = getattr(annotation, "__origin__", None) - # Handle Optional[X] which is Union[X, None] - if origin is typing.Union: + # Handle Optional[X] which is Union[X, None]. + # + # BOTH spellings must be accepted. `Optional[str]` / `Union[str, None]` carry + # `__origin__ is typing.Union`, but the PEP 604 form `str | None` is a + # `types.UnionType` whose origin is NOT typing.Union. Matching only the former + # made the two spellings of the SAME type disagree: `Optional[str]` was + # correctly optional while `str | None` fell through to the scalar path and was + # reported REQUIRED, so a tool written in modern syntax emitted a SWAIG schema + # demanding a parameter the author had made nullable. (Found 2026-07-29 when a + # pyupgrade autofix rewrote the tests to PEP 604 and they went red.) + if origin is typing.Union or isinstance(annotation, types.UnionType): args = annotation.__args__ non_none = [a for a in args if a is not type(None)] if len(non_none) == 1 and type(None) in args: @@ -288,6 +298,24 @@ def create_typed_handler_wrapper( """ def wrapper(args: dict[str, Any], raw_data: dict[str, Any] | None) -> Any: + """ + Call the wrapped typed handler with the SWAIG calling convention. + + Splats ``args`` into keyword arguments of the original function; when + the original declared a ``raw_data`` parameter, ``raw_data`` is passed + alongside as a keyword. No validation or coercion happens here — the + dict is assumed to already match the inferred schema, so a missing + required key surfaces as the original function's ``TypeError``. + + Args: + args: The SWAIG argument dict from the AI, keyed by parameter name. + raw_data: The full SWAIG POST body; forwarded only if the wrapped + function declared ``raw_data``, otherwise ignored. + + Returns: + Whatever the wrapped function returns (typically a + ``SwaigFunctionResult``). + """ if has_raw_data: return func(raw_data=raw_data, **args) return func(**args) diff --git a/signalwire/signalwire/core/agent_base.py b/signalwire/signalwire/core/agent_base.py index fcbe7b2c..7354b484 100644 --- a/signalwire/signalwire/core/agent_base.py +++ b/signalwire/signalwire/core/agent_base.py @@ -110,8 +110,13 @@ class AgentBase( # type: ignore[misc] # intentional diamond: WebMixin's serve/ 3. Declarative PROMPT_SECTIONS class attribute """ - # Subclasses can define this to declaratively set prompt sections - PROMPT_SECTIONS = None + # Subclasses can define this to declaratively set prompt sections. + # ClassVar: this is read off the CLASS (`cls.PROMPT_SECTIONS` in + # PromptMixin._process_prompt_sections) and is never assigned per-instance. + # Untyped, mypy inferred it as an INSTANCE variable, which made every + # subclass that correctly declared `PROMPT_SECTIONS: ClassVar[...]` a + # "Cannot override instance variable with class variable" [misc] error. + PROMPT_SECTIONS: ClassVar[dict[str, Any] | list[Any] | None] = None # Attributes set dynamically (on ephemeral copies / when native functions are # configured) rather than unconditionally in __init__. Declared here so the @@ -729,6 +734,33 @@ def enable_sip_routing( def sip_routing_callback( body: dict[str, Any], headers: dict[str, Any] ) -> str | None: + """ + Routing callback registered at ``path`` for inbound SIP requests. + + Pulls the SIP username out of the body with + ``extract_sip_username`` (the user part of the ``call.to`` SIP/TEL + URI) and logs whether it is one of this agent's registered + ``_sip_usernames``, compared lower-cased. + + **Always returns None**, on every branch — matched, unmatched, and + no-username-found alike. Under the routing contract + (``register_routing_callback``) None means "keep processing here", + so this endpoint never emits the 307 redirect that a non-None + return would produce: an unmatched username is logged and then + still handled by this agent rather than being sent elsewhere. The + match check is observational only. + + ``headers`` is part of the framework-free ``(body, headers)`` + callback shape shared with the other ports; this implementation + does not read it. + + Args: + body: Parsed JSON request body. + headers: Request headers (unused here). + + Returns: + Always None — continue normal processing at this route. + """ # Extract SIP username from the request body sip_username = self.extract_sip_username(body) @@ -1401,6 +1433,75 @@ async def _swaig_render_get_response( self.log.debug("swml_rendered", swml_size=len(swml)) return Response(content=swml, media_type="application/json") + def _swaig_validate_token( + self, + function_name: str, + token: str | None, + call_id: str | None, + ) -> dict[str, Any] | None: + """Enforce `secure=True` for one SWAIG call, independent of transport. + + A tool registered with secure=True REQUIRES a valid __token. An ABSENT + token is refused exactly like an invalid one -- omitting the credential + must never be weaker than presenting a wrong one, or `secure` would be + a flag that permits anonymous calls. + + The refusal shape is a 200 + FunctionResult body, NOT an HTTP error: + the engine (mod_openai) has no handling for a SWAIG refusal status, so + the tool reports that it cannot execute and the model relays it. + + Returns None to proceed, or the refusal dict to return instead. + """ + req_log = self.log.bind(endpoint="swaig", function=function_name) + + if not ( + hasattr(self, "_session_manager") + and function_name in self._tool_registry._swaig_functions + ): + return None + + if token: + req_log.debug("token_found", token_length=len(token)) + else: + req_log.warning("token_missing") + + # A token can only be validated against a call_id; without one there is + # nothing to check it against, so treat it as unvalidated. + if token and call_id is not None: + is_valid = bool( + self._session_manager.validate_tool_token(function_name, token, call_id) + ) + else: + is_valid = False + + if is_valid: + req_log.debug("token_valid") + return None + + if token: + req_log.warning("token_invalid") + if hasattr(self._session_manager, "debug_token"): + debug_info = self._session_manager.debug_token(token) + req_log.debug("token_debug", debug=json.dumps(debug_info)) + + func_entry = self._tool_registry._swaig_functions.get(function_name) + if func_entry and ( + func_entry.secure + if hasattr(func_entry, "secure") + else func_entry.get("secure", True) + ): + req_log.warning("secure_function_refused", token_present=bool(token)) + from signalwire.core.function_result import FunctionResult + + return FunctionResult( + response=( + "I'm sorry, the security token for this function is invalid " + "or expired. I cannot execute this action." + ) + ).to_dict() + + return None + def _swaig_pre_dispatch( self, request: Request, @@ -1410,39 +1511,12 @@ def _swaig_pre_dispatch( ) -> tuple[Any, dict[str, Any] | None]: req_log = self.log.bind(endpoint="swaig", function=function_name) - # Validate security token if present. + # Extract the credential from the HTTP query string, then hand the + # decision to the transport-agnostic core the serverless modes share. token = request.query_params.get("__token") or request.query_params.get("token") - if token: - req_log.debug("token_found", token_length=len(token)) - if ( - hasattr(self, "_session_manager") - and function_name in self._tool_registry._swaig_functions - and call_id is not None - ): - is_valid = self._session_manager.validate_tool_token( - function_name, token, call_id - ) - if is_valid: - req_log.debug("token_valid") - else: - req_log.warning("token_invalid") - if hasattr(self._session_manager, "debug_token"): - debug_info = self._session_manager.debug_token(token) - req_log.debug("token_debug", debug=json.dumps(debug_info)) - func_entry = self._tool_registry._swaig_functions.get(function_name) - if func_entry and ( - func_entry.secure - if hasattr(func_entry, "secure") - else func_entry.get("secure", True) - ): - from signalwire.core.function_result import FunctionResult - - return self, FunctionResult( - response=( - "I'm sorry, the security token for this function is invalid " - "or expired. I cannot execute this action." - ) - ).to_dict() + refusal = self._swaig_validate_token(function_name, token, call_id) + if refusal is not None: + return self, refusal # Dynamic-config ephemeral agent. target = self diff --git a/signalwire/signalwire/core/auth_handler.py b/signalwire/signalwire/core/auth_handler.py index 4d832c2c..36ae00af 100644 --- a/signalwire/signalwire/core/auth_handler.py +++ b/signalwire/signalwire/core/auth_handler.py @@ -8,7 +8,7 @@ """ import secrets -from typing import Any, TYPE_CHECKING +from typing import Any, Protocol, TYPE_CHECKING, runtime_checkable from collections.abc import Callable from functools import wraps @@ -45,6 +45,64 @@ logger = get_logger("auth_handler") +# --------------------------------------------------------------------------- +# Credential carriers +# --------------------------------------------------------------------------- +# +# ``verify_basic_auth`` and ``verify_bearer_token`` used to annotate their sole +# parameter with FastAPI's ``HTTPBasicCredentials`` / ``HTTPAuthorizationCredentials``. +# Neither method ever touched anything framework-specific: they read +# ``.username``/``.password`` and ``.credentials`` respectively and compare them with +# ``secrets.compare_digest``. The FIELDS are the contract; which web framework's class +# carries them is idiom — and FastAPI is an OPTIONAL dependency here (see the +# try/except above, which sets these names to ``None`` in a non-web install), so the +# annotation degraded to ``None`` exactly when FastAPI was absent. +# +# So the parameter types are declared structurally, as ``Protocol``s. A Protocol is +# strictly WIDER than the concrete class: a real FastAPI ``HTTPBasicCredentials`` +# still satisfies ``BasicCredentials`` with no change at any existing call site, and +# so does any object carrying the same attributes. +# +# The names and field sets match what the rest of the fleet converged on +# independently: 8 of the 9 ports already ship a ``BasicCredentials`` carrier of +# ``username``/``password`` and a ``BearerCredentials`` carrier of +# ``scheme``/``credentials`` (go is the exception — it passes the ``*http.Request`` +# or a scalar pair, which is the same contract expressed in its own idiom). +# +# Deliberately NO concrete value class is defined here: any object with the fields +# satisfies these, so adding one would be surface the ports would then have to +# mirror for nothing. + + +@runtime_checkable +class BasicCredentials(Protocol): + """HTTP Basic credentials parsed from the ``Authorization`` header. + + Structural: any object exposing ``username`` and ``password`` satisfies this, + including FastAPI's ``HTTPBasicCredentials``. + """ + + username: str + password: str + + +@runtime_checkable +class BearerCredentials(Protocol): + """HTTP Bearer/authorization credentials parsed from the ``Authorization`` header. + + Structural: any object exposing ``scheme`` and ``credentials`` satisfies this, + including FastAPI's ``HTTPAuthorizationCredentials``. + + ``scheme`` is the auth-scheme token as the client sent it (``Bearer``) and + ``credentials`` is the raw token following it. ``verify_bearer_token`` compares + only ``credentials``; ``scheme`` is carried because it is half of what the header + conveys and a caller cannot otherwise tell ``Bearer`` from another scheme. + """ + + scheme: str + credentials: str + + class AuthHandler: """ Unified authentication handler supporting multiple auth methods. @@ -95,7 +153,7 @@ def _setup_auth_methods(self) -> None: "header": getattr(self.security_config, "api_key_header", "X-API-Key"), } - def verify_basic_auth(self, credentials: HTTPBasicCredentials) -> bool: + def verify_basic_auth(self, credentials: BasicCredentials) -> bool: """Verify basic auth credentials""" if not self.auth_methods.get("basic", {}).get("enabled"): return False @@ -110,7 +168,7 @@ def verify_basic_auth(self, credentials: HTTPBasicCredentials) -> bool: return username_correct and password_correct - def verify_bearer_token(self, credentials: HTTPAuthorizationCredentials) -> bool: + def verify_bearer_token(self, credentials: BearerCredentials) -> bool: """Verify bearer token""" if not self.auth_methods.get("bearer", {}).get("enabled"): return False @@ -152,6 +210,32 @@ async def auth_dependency( else None, api_key: str | None = None, # Get from header in request ) -> dict[str, Any]: + """ + Authenticate a request from the FastAPI security schemes. + + Bearer is tried first, then Basic; the first scheme that verifies + wins and no later scheme is consulted. Both comparisons go through + ``secrets.compare_digest``. The ``api_key`` parameter is accepted + but NOT consulted here — API-key auth in this dependency is + unimplemented, so a request bearing only an API key is treated as + unauthenticated (``AuthHandler.flask_decorator`` does honour the + API-key header; this FastAPI path does not). + + On failure with ``optional=False`` this raises + ``HTTPException(401, detail="Invalid authentication credentials")`` + with a ``WWW-Authenticate: Basic`` header — Basic is always + advertised as the challenge, even when Bearer is the configured + scheme. With ``optional=True`` no exception is raised and the + handler runs with ``authenticated=False``. + + Returns: + ``{"authenticated": bool, "method": "bearer" | "basic" | None}``. + ``method`` is None whenever ``authenticated`` is False. + + Raises: + HTTPException: 401 when no scheme verifies and ``optional`` is + False. + """ # Try each auth method authenticated = False auth_method = None @@ -189,6 +273,33 @@ def flask_decorator(self, f: Callable[..., Any]) -> Callable[..., Any]: @wraps(f) def decorated(*args: Any, **kwargs: Any) -> Any: + """ + Authenticate the current Flask request, then call the view. + + Schemes are tried in this order, and the first that verifies calls + through to the wrapped view with the original ``*args``/``**kwargs``: + + 1. ``Authorization: Bearer `` — only if a bearer token is + configured; the token is everything after the 7-character + ``"Bearer "`` prefix. + 2. The configured API-key header (``X-API-Key`` unless + ``security_config.api_key_header`` overrides it). + 3. Flask's parsed ``request.authorization`` (HTTP Basic), matching + both username and password. + + Every comparison uses ``secrets.compare_digest``. + + On failure it logs an ``auth_failed`` event with the client IP, + method and path, and returns a Flask ``Response`` with body + ``"Authentication required"``, status **401**, and header + ``WWW-Authenticate: Basic realm="SignalWire Service"``. Note this + RETURNS a response rather than raising, and the challenge is always + Basic regardless of which schemes are enabled. + + Returns: + The wrapped view's return value on success, else the 401 + ``Response``. + """ from flask import request, Response # Try Bearer token first diff --git a/signalwire/signalwire/core/config_loader.py b/signalwire/signalwire/core/config_loader.py index 1ce12098..60625839 100644 --- a/signalwire/signalwire/core/config_loader.py +++ b/signalwire/signalwire/core/config_loader.py @@ -98,6 +98,24 @@ def substitute_vars(self, value: Any, max_depth: int = 10) -> Any: pattern = r"\$\{([^}|]+)(?:\|([^}]*))?\}" def replacer(match: "re.Match[str]") -> str: + """ + Expand one ``${VAR}`` / ``${VAR|default}`` match. + + Group 1 is the variable name, group 2 the optional default + after the ``|``. Returns ``os.environ[VAR]`` when set, + otherwise the default; an absent default (``${VAR}`` with no + pipe) and an empty one (``${VAR|}``) both yield the empty + string. A missing variable is never an error and the ``${...}`` + text is never left in place. + + Args: + match: The regex match for a single ``${...}`` occurrence. + + Returns: + The replacement text, always a string — the caller + (``substitute_vars``) re-types the fully-substituted result + to bool/int/float afterwards. + """ var_name = match.group(1) default = match.group(2) if match.group(2) is not None else "" return os.environ.get(var_name, default) diff --git a/signalwire/signalwire/core/data_map.py b/signalwire/signalwire/core/data_map.py index e3ff8710..da10b612 100644 --- a/signalwire/signalwire/core/data_map.py +++ b/signalwire/signalwire/core/data_map.py @@ -56,7 +56,7 @@ class DataMap: .purpose('Search documentation') .parameter('query', 'string', 'Search query', required=True) .webhook('POST', 'https://api.docs.com/search', headers={'Authorization': 'Bearer TOKEN'}) - .body({'query': '${query}', 'limit': 3}) + .params({'query': '${query}', 'limit': 3}) .output(FunctionResult('Found: ${response.results[0].title} - ${response.results[0].summary}')) .foreach('${response.results}') ) @@ -257,25 +257,16 @@ def webhook_expressions(self, expressions: list[dict[str, Any]]) -> "DataMap": self._webhooks[-1]["expressions"] = expressions return self - def body(self, data: dict[str, Any]) -> "DataMap": - """ - Set request body for the last added webhook (POST/PUT requests) - - Args: - data: Request body data (can include ${variable} substitutions) - - Returns: - Self for method chaining - """ - if not self._webhooks: - raise ValueError("Must add webhook before setting body") - - self._webhooks[-1]["body"] = data - return self - def params(self, data: dict[str, Any]) -> "DataMap": """ - Set request params for the last added webhook (alias for body) + Set request params for the last added webhook. + + This is NOT an alias for body(): the two write different webhook keys + (``params`` vs ``body``), and only ``params`` is part of the webhook + contract — schema.json ``$defs/Webhook`` lists ``params`` among its ten + permitted properties and forbids everything else, and the engine's + webhook readers look up ``params`` and never ``body``. Use this method + for POST/PUT request data. Args: data: Request params data (can include ${variable} substitutions) @@ -447,7 +438,6 @@ def create_simple_api_tool( parameters: dict[str, dict[str, Any]] | None = None, method: str = "GET", headers: dict[str, str] | None = None, - body: dict[str, Any] | None = None, error_keys: list[str] | None = None, ) -> DataMap: """ @@ -460,7 +450,6 @@ def create_simple_api_tool( parameters: Optional parameter definitions method: HTTP method (default: GET) headers: Optional HTTP headers - body: Optional request body (for POST/PUT) error_keys: Optional list of error indicator keys Returns: @@ -482,10 +471,6 @@ def create_simple_api_tool( # Add webhook data_map.webhook(method, url, headers) - # Add body if provided - if body: - data_map.body(body) - # Add error keys if provided if error_keys: data_map.error_keys(error_keys) diff --git a/signalwire/signalwire/core/logging_config.py b/signalwire/signalwire/core/logging_config.py index 98d1258d..4ecc5d8b 100644 --- a/signalwire/signalwire/core/logging_config.py +++ b/signalwire/signalwire/core/logging_config.py @@ -24,14 +24,13 @@ import sys import structlog +from collections.abc import Callable from typing import Any _CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]") -def strip_control_chars( - logger: Any, method_name: str, event_dict: dict[str, Any] -) -> dict[str, Any]: +def strip_control_chars(event_dict: dict[str, Any]) -> dict[str, Any]: """Strip control characters from log event values to prevent log injection.""" for key, value in event_dict.items(): if isinstance(value, str): @@ -39,6 +38,41 @@ def strip_control_chars( return event_dict +class _as_processor: + """Adapt a single-argument event-dict transform to structlog's processor protocol. + + structlog calls every processor as ``(logger, method_name, event_dict)``, but a + transform like :func:`strip_control_chars` only ever needs the event dict. This + keeps the public function honest — one parameter, the thing it actually uses — + and confines the structlog plumbing to the registration sites. + + Two adapters wrapping the same transform compare equal, so a processor chain can + be tested for membership. + """ + + __slots__ = ("_transform",) + + def __init__(self, transform: Callable[[dict[str, Any]], dict[str, Any]]) -> None: + self._transform = transform + + def __call__( + self, logger: Any, method_name: str, event_dict: dict[str, Any] + ) -> dict[str, Any]: + return self._transform(event_dict) + + def __eq__(self, other: object) -> bool: + if isinstance(other, _as_processor): + return self._transform == other._transform + return NotImplemented + + def __hash__(self) -> int: + return hash(self._transform) + + def __repr__(self) -> str: + name = getattr(self._transform, "__name__", repr(self._transform)) + return f"<_as_processor {name}>" + + # Global flag to ensure configuration only happens once _logging_configured = False @@ -58,8 +92,39 @@ def _install_library_null_handler() -> None: sw_logger.addHandler(logging.NullHandler()) -# Import-time side effect: ONLY the library NullHandler. NOT global configuration. -_install_library_null_handler() +def _install_library_defaults() -> None: + """Make the SDK's UNCONFIGURED logging genuinely silent. + + The NullHandler above only silences records that reach the *stdlib*. But + structlog's own out-of-the-box default is ``PrintLoggerFactory()``, which + writes straight to ``sys.stdout`` and never touches stdlib at all — so + "the app never called ``configure_logging()``" did not mean "silent", it + meant "print every SDK log line to stdout". That is how debug output ended + up interleaved with the JSON on ``swaig-test --dump-swml --raw``. + + Binding the stdlib ``LoggerFactory`` here routes every ``get_logger()`` + record through the ``signalwire`` namespace logger, where the NullHandler + is already waiting. Silent by default, exactly as documented, and still no + handler/level/propagate change on any host-owned logger. + + Only applied when the host has not configured structlog itself — an app + that owns its structlog config keeps it. + """ + if structlog.is_configured(): + return + structlog.configure( + processors=[ + *_get_structlog_processors(), + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=False, + ) + + +# (the import-time invocation of both installers lives at the END of this module, +# where `_get_structlog_processors` is already defined) def get_execution_mode() -> str: @@ -106,6 +171,22 @@ def reset_logging_configuration() -> None: global _logging_configured _logging_configured = False structlog.reset_defaults() + # reset_defaults() restores structlog's OWN default — the stdout PrintLogger. + # Re-install the library default so a reset returns us to silent, not loud. + _install_library_defaults() + + +# CLI flags that turn stdout into a DATA channel: the caller pipes it into `jq` +# or `json.loads`, so a single log line on stdout corrupts the payload. Kept in +# ONE place because the original bug was list DRIFT — `_detect_colors()` knew +# about `--raw`/`--dump-swml` and the stream decision did not, so the flags +# suppressed ANSI colour while still writing the logs into the JSON. +_MACHINE_READABLE_STDOUT_FLAGS = frozenset({"--raw", "--dump-swml", "--json"}) + + +def _machine_readable_stdout() -> bool: + """True when a CLI flag makes stdout a machine-readable data channel.""" + return not _MACHINE_READABLE_STDOUT_FLAGS.isdisjoint(sys.argv) def _detect_colors() -> bool: @@ -119,7 +200,7 @@ def _detect_colors() -> bool: return False if not stream.isatty(): return False - return not ("--raw" in sys.argv or "--dump-swml" in sys.argv) + return not _machine_readable_stdout() def configure_logging() -> None: @@ -141,10 +222,23 @@ def configure_logging() -> None: log_level = os.getenv("SIGNALWIRE_LOG_LEVEL", "info").lower() log_format = os.getenv("SIGNALWIRE_LOG_FORMAT", "console").lower() - # Determine log mode if auto or not specified + # Determine log mode if auto or not specified. + # + # PRECEDENCE, deliberately: an explicit SIGNALWIRE_LOG_MODE always wins — an + # operator who asks for `default`, `stderr`, or `off` gets exactly that, even + # under `--raw`. The flag inference only ever replaces the mode we would have + # GUESSED. Within the inference, a machine-readable stdout outranks the + # server default (logs move to stderr, where they stay visible and stop + # corrupting the payload) but not CGI's `off`, where stdout is the HTTP + # response body and stderr is the server error log. if not log_mode or log_mode == "auto": execution_mode = get_execution_mode() - log_mode = "off" if execution_mode == "cgi" else "default" + if execution_mode == "cgi": + log_mode = "off" + elif _machine_readable_stdout(): + log_mode = "stderr" + else: + log_mode = "default" # Configure based on mode if log_mode == "off": @@ -168,7 +262,7 @@ def _get_structlog_processors() -> list[Any]: structlog.processors.StackInfoRenderer(), structlog.processors.format_exc_info, structlog.processors.UnicodeDecoder(), - strip_control_chars, + _as_processor(strip_control_chars), ] @@ -196,7 +290,7 @@ def _get_formatter_processors() -> list[Any]: structlog.processors.StackInfoRenderer(), structlog.processors.format_exc_info, structlog.processors.UnicodeDecoder(), - strip_control_chars, + _as_processor(strip_control_chars), _drop_internal_keys, ] @@ -332,3 +426,12 @@ def get_logger(name: str) -> Any: # logger carries a NullHandler (installed at module load) so it's silent by # default; the app opts in to SDK output via configure_logging(). return structlog.get_logger(name) + + +# Import-time side effects, in order. Neither configures OUTPUT — together they +# are what makes "silent by default" true: +# 1. the NullHandler on the `signalwire` stdlib namespace, and +# 2. the stdlib logger factory, so structlog records actually REACH that +# namespace instead of structlog's default stdout PrintLogger. +_install_library_null_handler() +_install_library_defaults() diff --git a/signalwire/signalwire/core/mixins/serverless_mixin.py b/signalwire/signalwire/core/mixins/serverless_mixin.py index 991ea9f2..c238aa6b 100644 --- a/signalwire/signalwire/core/mixins/serverless_mixin.py +++ b/signalwire/signalwire/core/mixins/serverless_mixin.py @@ -12,6 +12,7 @@ import json import re from typing import Any +from urllib.parse import parse_qs from signalwire.core.logging_config import get_execution_mode from signalwire.core.function_result import FunctionResult @@ -21,6 +22,38 @@ MAX_CGI_BODY_SIZE = 10 * 1024 * 1024 +def _token_from_params(params: Any) -> str | None: + """Pick the `__token` credential out of an already-parsed query mapping. + + Mirrors the HTTP path exactly: read `__token`, fall back to the bare + `token` spelling. A falsy value is treated as absent. + """ + if not params: + return None + try: + token = params.get("__token") or params.get("token") + except AttributeError: + return None + return str(token) if token else None + + +def _token_from_query_string(query_string: Any) -> str | None: + """Pick the `__token` credential out of a RAW `a=b&c=d` query string.""" + if not query_string: + return None + if isinstance(query_string, bytes): + query_string = query_string.decode("utf-8", errors="replace") + if not isinstance(query_string, str): + return None + # Strip a leading '?' so a full "?a=b" fragment parses the same as "a=b". + parsed = parse_qs(query_string.lstrip("?")) + for key in ("__token", "token"): + values = parsed.get(key) + if values and values[0]: + return str(values[0]) + return None + + class ServerlessMixin(_HostTyped): # type: ignore[misc] # _HostTyped is object at runtime; AgentBase under TYPE_CHECKING — intentional split """ Mixin class containing all serverless/cloud platform methods for AgentBase @@ -55,6 +88,8 @@ def handle_serverless_request( path_info = os.getenv("PATH_INFO", "").strip("/") if not path_info: return self._render_swml() + # CGI carries the query string in the QUERY_STRING env var. + token = _token_from_query_string(os.getenv("QUERY_STRING")) # Parse CGI request for SWAIG function call args = {} call_id = None @@ -94,7 +129,9 @@ def handle_serverless_request( # If parsing fails, continue with empty args pass - return self._execute_swaig_function(path_info, args, call_id, raw_data) + return self._execute_swaig_function( + path_info, args, call_id, raw_data, token + ) if mode == "lambda": # Check authentication in Lambda mode @@ -108,6 +145,14 @@ def handle_serverless_request( if not path and event.get("pathParameters"): path = event.get("pathParameters", {}).get("proxy", "") + # Both payload shapes are reachable here, and they carry the + # query string differently: REST API v1 (and HTTP API v2) + # provide the parsed `queryStringParameters` mapping, while + # HTTP API v2 may instead provide the raw `rawQueryString`. + token = _token_from_params( + event.get("queryStringParameters") + ) or _token_from_query_string(event.get("rawQueryString")) + # Parse request body if present args = {} call_id = None @@ -157,7 +202,7 @@ def handle_serverless_request( if path in ("swaig", "swaig/") and function_name: # /swaig endpoint with function name in body result = self._execute_swaig_function( - function_name, args, call_id, raw_data + function_name, args, call_id, raw_data, token ) return { "statusCode": 200, @@ -169,7 +214,7 @@ def handle_serverless_request( if path and path not in ("", "swaig", "swaig/"): # Path-based function routing (e.g., /say_hello) result = self._execute_swaig_function( - path, args, call_id, raw_data + path, args, call_id, raw_data, token ) return { "statusCode": 200, @@ -227,6 +272,7 @@ def _execute_swaig_function( args: dict[str, Any] | None = None, call_id: str | None = None, raw_data: dict[str, Any] | None = None, + token: str | None = None, ) -> dict[str, Any]: """ Execute a SWAIG function in serverless context @@ -236,6 +282,9 @@ def _execute_swaig_function( args: Function arguments dictionary call_id: Optional call ID raw_data: Optional raw request data + token: Optional `__token` credential extracted from the caller's + query string. `secure=True` tools are enforced against it + exactly as on the HTTP transport; an absent token is refused. Returns: Function execution result @@ -263,6 +312,14 @@ def _execute_swaig_function( ) return {"error": f"Function '{function_name}' not found"} + # Enforce `secure=True` through the same transport-agnostic core + # the HTTP path uses, so serverless cannot drift from HTTP. + validate = getattr(self, "_swaig_validate_token", None) + if validate is not None: + refusal = validate(function_name, token, call_id) + if refusal is not None: + return dict(refusal) + # Use empty args if not provided if args is None: args = {} @@ -328,6 +385,12 @@ def _handle_google_cloud_function_request(self, request: Any) -> Any: # Get the path from the request path = request.path.strip("/") + # Flask exposes the parsed query as `request.args`; fall back to the + # raw `request.query_string` when a shim provides only that. + token = _token_from_params( + getattr(request, "args", None) + ) or _token_from_query_string(getattr(request, "query_string", None)) + # Try to detect and set the base URL from the request for webhook URLs base_url = None if hasattr(request, "url") and request.url: @@ -383,7 +446,7 @@ def _handle_google_cloud_function_request(self, request: Any) -> Any: if path in ("swaig", "swaig/") and function_name: # /swaig endpoint with function name in body result = self._execute_swaig_function( - function_name, args, call_id, raw_data + function_name, args, call_id, raw_data, token ) return Response( response=json.dumps(result) @@ -394,7 +457,9 @@ def _handle_google_cloud_function_request(self, request: Any) -> Any: ) if path and path not in ("", "swaig", "swaig/"): # Path-based function routing (e.g., /say_hello) - result = self._execute_swaig_function(path, args, call_id, raw_data) + result = self._execute_swaig_function( + path, args, call_id, raw_data, token + ) return Response( response=json.dumps(result) if isinstance(result, dict) @@ -442,8 +507,11 @@ def _handle_azure_function_request(self, req: Any) -> Any: base_url = None if req.url: parsed = urlparse(req.url) - # Full path after /api/ e.g. "function_app" or "function_app/swaig" - url_parts = req.url.split("/api/") + # Full path after /api/ e.g. "function_app" or "function_app/swaig". + # Split the PARSED path, not the raw URL: the raw URL still + # carries "?__token=..." and would otherwise be baked into the + # function name (e.g. "say_hello?__token=abc" -> not found). + url_parts = parsed.path.split("/api/") if len(url_parts) > 1: full_path = url_parts[1].strip("/") # Split into function name and sub-path @@ -463,6 +531,12 @@ def _handle_azure_function_request(self, req: Any) -> Any: if base_url and not getattr(self, "_proxy_url_base_from_env", False): self._proxy_url_base = base_url + # Azure exposes the parsed query as `req.params`; fall back to the + # query component of `req.url` when a shim provides only the URL. + token = _token_from_params( + getattr(req, "params", None) + ) or _token_from_query_string(urlparse(req.url).query if req.url else None) + # Parse request body if present args = {} call_id = None @@ -503,7 +577,7 @@ def _handle_azure_function_request(self, req: Any) -> Any: if path in ("swaig", "swaig/") and function_name: # /swaig endpoint with function name in body result = self._execute_swaig_function( - function_name, args, call_id, raw_data + function_name, args, call_id, raw_data, token ) return func.HttpResponse( body=json.dumps(result) @@ -514,7 +588,9 @@ def _handle_azure_function_request(self, req: Any) -> Any: ) if path and path not in ("", "api", "swaig", "swaig/"): # Path-based function routing (e.g., /say_hello) - result = self._execute_swaig_function(path, args, call_id, raw_data) + result = self._execute_swaig_function( + path, args, call_id, raw_data, token + ) return func.HttpResponse( body=json.dumps(result) if isinstance(result, dict) diff --git a/signalwire/signalwire/core/mixins/tool_mixin.py b/signalwire/signalwire/core/mixins/tool_mixin.py index c1da88c4..b1e340b2 100644 --- a/signalwire/signalwire/core/mixins/tool_mixin.py +++ b/signalwire/signalwire/core/mixins/tool_mixin.py @@ -298,6 +298,7 @@ def _execute_swaig_function( args: dict[str, Any] | None = None, call_id: str | None = None, raw_data: dict[str, Any] | None = None, + token: str | None = None, ) -> dict[str, Any]: """ Execute a SWAIG function in serverless context @@ -307,6 +308,9 @@ def _execute_swaig_function( args: Function arguments dictionary call_id: Optional call ID raw_data: Optional raw request data + token: Optional `__token` credential extracted from the caller's + query string. `secure=True` tools are enforced against it + exactly as on the HTTP transport; an absent token is refused. Returns: Function execution result @@ -330,6 +334,14 @@ def _execute_swaig_function( ) return {"error": f"Function '{function_name}' not found"} + # Enforce `secure=True` through the same transport-agnostic core + # the HTTP path uses, so serverless cannot drift from HTTP. + validate = getattr(self, "_swaig_validate_token", None) + if validate is not None: + refusal = validate(function_name, token, call_id) + if refusal is not None: + return dict(refusal) + # Use empty args if not provided if args is None: args = {} diff --git a/signalwire/signalwire/core/mixins/web_mixin.py b/signalwire/signalwire/core/mixins/web_mixin.py index e635c978..1375573e 100644 --- a/signalwire/signalwire/core/mixins/web_mixin.py +++ b/signalwire/signalwire/core/mixins/web_mixin.py @@ -120,6 +120,31 @@ async def readiness_check() -> dict[str, str]: async def add_security_headers( request: Request, call_next: Callable[[Request], Awaitable[Response]] ) -> Response: + """ + HTTP middleware that stamps security headers on every response. + + Runs the downstream handler first, then unconditionally sets: + + - ``X-Content-Type-Options: nosniff`` + - ``X-Frame-Options: DENY`` + - ``Referrer-Policy: strict-origin-when-cross-origin`` + + and additionally, only when SSL is on (either ``_ssl_enabled`` + or ``ssl_enabled`` is truthy): + + - ``Strict-Transport-Security: max-age=31536000; includeSubDomains`` + + Headers are assigned, not appended, so they override anything a + handler set. HSTS is gated because sending it over plain HTTP + would pin clients to a scheme this process is not serving. + + Args: + request: The incoming request, passed through untouched. + call_next: The next handler in the middleware chain. + + Returns: + The downstream response with the headers added. + """ response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" @@ -151,6 +176,32 @@ async def add_security_headers( @app.get("/{full_path:path}") @app.post("/{full_path:path}") async def handle_all_routes(request: Request, full_path: str) -> Response: + """ + Catch-all fallback for paths the mounted router did not match. + + Registered last, so the real endpoints (``/health``, + ``/ready``, and everything on the agent's router prefix) win. + It only classifies the leftovers, and does NOT dispatch: + + - A path that does not begin with this agent's route → + ``JSONResponse({"error": "Invalid route"})``. + - Anything else → an empty **204** response. + + Both are 200-family: the error case returns 200 with an error + body (the status code is left at FastAPI's default), and the + on-route case returns 204 rather than being handled. This is + the ``get_app()`` variant, used for serverless/ASGI adapters + such as Mangum; the ``serve()`` variant of this name is the one + that actually routes to ``/swaig``, ``/post_prompt`` and the + registered callbacks. + + Args: + request: The incoming request (used for logging only). + full_path: The matched path with no leading slash. + + Returns: + The error JSON body, or a 204. + """ self.log.debug("request_received", path=full_path) # Check if the path is meant for this agent @@ -246,6 +297,29 @@ async def readiness_check() -> Response: async def add_security_headers( request: Request, call_next: Callable[[Request], Awaitable[Response]] ) -> Response: + """ + HTTP middleware that stamps security headers on every response. + + Identical to the middleware installed by ``get_app()`` — the + two entry points build independent FastAPI apps, so each + registers its own copy. Runs the downstream handler, then + unconditionally sets: + + - ``X-Content-Type-Options: nosniff`` + - ``X-Frame-Options: DENY`` + - ``Referrer-Policy: strict-origin-when-cross-origin`` + + plus, only when ``_ssl_enabled`` or ``ssl_enabled`` is truthy: + + - ``Strict-Transport-Security: max-age=31536000; includeSubDomains`` + + Args: + request: The incoming request, passed through untouched. + call_next: The next handler in the middleware chain. + + Returns: + The downstream response with the headers added. + """ response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" @@ -265,6 +339,42 @@ async def add_security_headers( @app.get("/{full_path:path}") @app.post("/{full_path:path}") async def handle_all_routes(request: Request, full_path: str) -> Response: + """ + Catch-all that dispatches this agent's endpoints by path + suffix. + + Registered before the router is included, and it matches + everything, so under ``serve()`` this — not the router — is + what handles the agent's endpoints. A path not starting with + the agent's route returns ``JSONResponse({"error": "Invalid + route"})``. Otherwise the remainder after the route prefix is + stripped of slashes and dispatched: + + - empty (the route root) → ``_handle_root_request`` (the SWML + document) + - ``debug`` → ``_handle_debug_request`` + - ``swaig`` → ``_handle_swaig_request`` + - ``post_prompt`` → ``_handle_post_prompt_request`` + - ``check_for_input`` → ``_handle_check_for_input_request`` + - ``debug_events`` → ``_handle_debug_events_request`` + - an exact match against a registered routing-callback path → + that path is stashed on ``request.state.callback_path`` and + the request goes to ``_handle_root_request`` + + Anything else returns ``JSONResponse({"error": "Path not + found"})``. Both error bodies come back with FastAPI's default + **200** status, not 404 — callers must read the body to detect + a miss. Dict-returning internal handlers are normalized through + ``_as_response``. + + Args: + request: The incoming request. + full_path: The matched path with no leading slash. + + Returns: + The dispatched handler's response, or one of the two error + bodies above. + """ self.log.debug("request_received", path=full_path) # Check if the path is meant for this agent @@ -1380,6 +1490,24 @@ def setup_graceful_shutdown(self) -> None: """ def signal_handler(signum: int, frame: Any) -> None: + """ + Log the shutdown signal, run cleanup, and exit the process. + + Installed for both SIGTERM (what Kubernetes sends) and SIGINT + (Ctrl+C). The cleanup block is currently a no-op placeholder — it + checks for ``_session_manager`` but performs no teardown — and any + exception raised inside it is logged as ``cleanup_error`` and + swallowed. Either way the ``finally`` clause calls ``sys.exit(0)``, + so the process always terminates with status 0 and cleanup failure + never blocks shutdown. + + Because it exits from a signal handler, in-flight requests are not + drained. + + Args: + signum: The signal number that fired. + frame: The interrupted stack frame (unused). + """ self.log.info("shutdown_signal_received", signal=signum) # Perform cleanup diff --git a/signalwire/signalwire/core/post_prompt_generated.py b/signalwire/signalwire/core/post_prompt_generated.py index 659fa3e5..b677404b 100644 --- a/signalwire/signalwire/core/post_prompt_generated.py +++ b/signalwire/signalwire/core/post_prompt_generated.py @@ -9,9 +9,10 @@ from typing import Any, Literal, TypeAlias, TypedDict from typing import TYPE_CHECKING -# SwaigRequest is generated in swaig_request_generated; aliased here for the -# swaig_log entry's post_data field. +# Types owned by sibling swaig specs, imported so the cross-file +# $ref fields below resolve to the real type rather than a dict. if TYPE_CHECKING: + from signalwire.core.swaig_actions_generated import SwaigResponse as SwaigResponse from signalwire.core.swaig_request_generated import SwaigRequest as SwaigRequest @@ -69,7 +70,7 @@ class PostPrompt(TypedDict, total=False): class PostPromptData(TypedDict, total=False): """Open shape: extra server keys permitted; not validated at runtime.""" - parsed: list[dict[str, Any]] + parsed: list[dict[str, Any] | list[Any]] raw: str substituted: str @@ -160,14 +161,40 @@ class PostPromptSystemLogEntry(TypedDict, total=False): role: str content: str timestamp: int - action: str + action: Literal[ + "attention_timeout", + "attention_wait", + "auto_correct", + "change_step_failed", + "check_for_input", + "context_enter", + "double_turn", + "filler", + "function_call", + "function_error", + "function_loop", + "gather_answer", + "gather_complete", + "gather_question", + "gather_reject", + "gather_start", + "hangup_hook", + "hearing_hint", + "inner_dialog", + "inner_dialog_scorecard", + "manual_say", + "reset", + "session_end", + "session_start", + "startup_hook", + "step_change", + "summarize_start", + "swaig_problem", + ] lang: str tokens: int content_type: str metadata: dict[str, Any] - context: str - step: str - step_index: int class PostPromptSystemEntry(TypedDict, total=False): @@ -184,16 +211,16 @@ class PostPromptSwaigLogEntry(TypedDict, total=False): command_name: str command_arg: str epoch_time: int - native: bool + native: Literal[True] active_count: int | Literal["endless"] url: str post_data: SwaigRequest - post_response: dict[str, Any] - delayed_post_response: dict[str, Any] + post_response: SwaigResponse + delayed_post_response: SwaigResponse mcp_url: str mcp_tool: str - mcp_response: dict[str, Any] - mcp_error: str + mcp_response: str + mcp_error: Literal[True] class PostPromptTimesEntry(TypedDict, total=False): diff --git a/signalwire/signalwire/core/security/webhook_middleware.py b/signalwire/signalwire/core/security/webhook_middleware.py index 5d31254f..ddd0564f 100644 --- a/signalwire/signalwire/core/security/webhook_middleware.py +++ b/signalwire/signalwire/core/security/webhook_middleware.py @@ -176,6 +176,32 @@ def _forbidden() -> NoReturn: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) async def dependency(request: Request, response: Response) -> None: + """Validate this request's SignalWire webhook signature. + + Reads the raw body first and stashes the bytes on + ``request.state.raw_body`` so the endpoint can re-parse them without + re-reading the (already-consumed) stream, and so the HMAC is computed + over the exact bytes received rather than a re-serialization. + Reconstructs the public URL via ``_reconstruct_url`` (``SWML_PROXY_URL_BASE`` + > ``X-Forwarded-*`` when ``trust_proxy`` > ``request.url``), then hands + the primitives to :func:`validate`. + + Every rejection path — a body that is not valid UTF-8, a missing + signature header, a signature that does not verify — raises the same + bare ``HTTPException(403)`` with no body detail, so a caller cannot + tell which check failed. On success it returns None and FastAPI runs + the endpoint. + + Args: + request: The incoming request; its body is consumed here. + response: FastAPI-injected response object. Present to satisfy the + dependency signature; this function does not write to it + (rejection is by raised exception, since returning a Response + from a ``dependencies=[...]`` entry does not short-circuit). + + Raises: + HTTPException: 403 on any validation failure. + """ # Capture raw body BEFORE any other consumer reads the stream. # request.body() caches internally so subsequent calls are safe. raw_bytes = await request.body() diff --git a/signalwire/signalwire/core/swaig_actions_generated.py b/signalwire/signalwire/core/swaig_actions_generated.py index f967a192..58fd9671 100644 --- a/signalwire/signalwire/core/swaig_actions_generated.py +++ b/signalwire/signalwire/core/swaig_actions_generated.py @@ -17,171 +17,226 @@ class ContextSwitchAction(TypedDict, total=False): """Open shape: extra server keys permitted; not validated at runtime.""" - system_prompt: Any - user_prompt: Any - system_pom: Any - user_pom: Any consolidate: bool full_reset: bool + system_pom: dict[str, Any] + system_prompt: str + user_pom: dict[str, Any] + user_prompt: str class HoldAction(TypedDict, total=False): """Open shape: extra server keys permitted; not validated at runtime.""" - timeout: int + timeout: float | str class PlaybackBgAction(TypedDict, total=False): """Open shape: extra server keys permitted; not validated at runtime.""" - file: Any + file: str wait: bool class TransferAction(TypedDict, total=False): """Open shape: extra server keys permitted; not validated at runtime.""" - dest: Any + dest: str summarize: bool +class SwaigAction(TypedDict, total=False): + """A response-action object. The keys below are the full vocabulary dispatched by actions.c::process_action; an action object sets one or more of them. Each key's source line is the engine dispatch site. + + Open shape: extra server keys permitted; not validated at runtime. + """ + + SWML: str | dict[str, Any] + add_dynamic_hints: list[dict[str, Any] | str] + back_to_back_functions: bool | Literal["forever"] + change_context: str + change_step: str + clear_dynamic_hints: bool | str + context_switch: str | ContextSwitchAction + end_of_speech_timeout: int + extensive_data: bool | str + functions_on_speaker_timeout: bool | str + hangup: bool | str + hold: int | str | HoldAction + playback_bg: str | PlaybackBgAction + replace_in_history: str | Literal[True] + say: str + set_global_data: dict[str, Any] + set_meta_data: dict[str, Any] + settings: dict[str, Any] + speech_event_timeout: int + stop: bool | str + stop_playback_bg: bool | str | int | dict[str, Any] | list[Any] | None + toggle_functions: list[dict[str, Any]] + transfer: str | TransferAction + unset_global_data: str | list[str] + unset_meta_data: str | list[str] + user_event: dict[str, Any] + user_input: str + wait_for_user: bool | int | Literal["answer_first"] + + +class SwaigResponse(TypedDict, total=False): + """Parsed at actions.c:2228-2276. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + response: str + action: SwaigAction | list[SwaigAction] + post_process: bool + + class _SwaigActions: """Typed SWAIG response-action builders (one per wire action). The host class provides ``self.action`` (the list serialized to the wire).""" - def add_dynamic_hints(self: _Self, value: list[Any]) -> _Self: - """Add ASR hints. Strings go to `dynamic_hints`; `{hint, ...}` objects go to `dynamic_hearing_hints` (and the `hint` value is also added to `dynamic_hints`). Restarts speech detection""" # actions.c:547 + def SWML(self: _Self, value: str | dict[str, Any]) -> _Self: + """Execute a SWML document inline, or with sibling `transfer:true` transfer the call into it. Gated by `swaig_allow_swml`. **Transfer additionally requires `from_relay`** (`actions.c:142-145`); inline execution captures an optional `ai_response` SWML var back into the conversation""" # actions.c:129 + self.action.append({"SWML": value}) # type: ignore[attr-defined] + return self + + def add_dynamic_hints(self: _Self, value: list[dict[str, Any] | str]) -> _Self: + """Add ASR hints. Strings go to `dynamic_hints`; `{hint, ...}` objects go to `dynamic_hearing_hints` (and the `hint` value is also added to `dynamic_hints`). Restarts speech detection""" # actions.c:550 self.action.append({"add_dynamic_hints": value}) # type: ignore[attr-defined] return self def back_to_back_functions(self: _Self, value: bool | Literal["forever"]) -> _Self: - """Allow consecutive function calls without a user turn. `true` = `1`, `"forever"` = `2`""" # actions.c:359 + """Allow consecutive function calls without a user turn. `true` = `1`, `"forever"` = `2`""" # actions.c:362 self.action.append({"back_to_back_functions": value}) # type: ignore[attr-defined] return self def change_context(self: _Self, value: str) -> _Self: - """Switch to a named **context** (same machinery as the `change_context` function)""" # actions.c:238 + """Switch to a named **context** (same machinery as the `change_context` function)""" # actions.c:241 self.action.append({"change_context": value}) # type: ignore[attr-defined] return self def change_step(self: _Self, value: str) -> _Self: - """Switch to a named **step** (or `"next"`)""" # actions.c:248 + """Switch to a named **step** (or `"next"`)""" # actions.c:251 self.action.append({"change_step": value}) # type: ignore[attr-defined] return self - def clear_dynamic_hints(self: _Self, value: dict[str, Any]) -> _Self: - """Clear both dynamic hint lists and restart speech detection""" # actions.c:579 + def clear_dynamic_hints(self: _Self, value: bool | str) -> _Self: + """Clear both dynamic hint lists and restart speech detection""" # actions.c:582 self.action.append({"clear_dynamic_hints": value}) # type: ignore[attr-defined] return self def context_switch(self: _Self, value: str | ContextSwitchAction) -> _Self: - """Replace the system prompt / start a new conversation context. Object form: `{system_prompt, user_prompt, system_pom, user_pom, consolidate, full_reset}`. `system_pom`/`user_pom` render to prompt text; prompts are expanded against prompt vars + post_data; `consolidate:true` summarizes first""" # actions.c:594 + """Replace the system prompt / start a new conversation context. Object form: `{system_prompt, user_prompt, system_pom, user_pom, consolidate, full_reset}`. `system_pom`/`user_pom` render to prompt text; prompts are expanded against prompt vars + post_data; `consolidate:true` summarizes first""" # actions.c:597 self.action.append({"context_switch": value}) # type: ignore[attr-defined] return self def end_of_speech_timeout(self: _Self, value: int) -> _Self: - """Set end-of-speech detection timeout (must be >0)""" # actions.c:312 + """Set end-of-speech detection timeout (must be >0)""" # actions.c:315 self.action.append({"end_of_speech_timeout": value}) # type: ignore[attr-defined] return self - def extensive_data(self: _Self, value: bool) -> _Self: - """Enable extensive data in the function/conversation log""" # actions.c:373 + def extensive_data(self: _Self, value: bool | str) -> _Self: + """Enable extensive data in the function/conversation log""" # actions.c:376 self.action.append({"extensive_data": value}) # type: ignore[attr-defined] return self - def functions_on_speaker_timeout(self: _Self, value: bool) -> _Self: - """Set whether functions may fire on speaker timeout""" # actions.c:369 + def functions_on_speaker_timeout(self: _Self, value: bool | str) -> _Self: + """Set whether functions may fire on speaker timeout""" # actions.c:372 self.action.append({"functions_on_speaker_timeout": value}) # type: ignore[attr-defined] return self - def hangup(self: _Self, value: dict[str, Any]) -> _Self: - """Set `offhook = 0` (hang up). Note: a graceful "say goodbye" hangup is the **built-in `hangup` function**, not this action""" # actions.c:294 + def hangup(self: _Self, value: bool | str) -> _Self: + """Set `offhook = 0` (hang up). Note: a graceful "say goodbye" hangup is the **built-in `hangup` function**, not this action""" # actions.c:297 self.action.append({"hangup": value}) # type: ignore[attr-defined] return self def hold(self: _Self, value: int | str | HoldAction) -> _Self: - """Put the call on hold for N seconds. Accepts a number, a time string (`"5m"`, `"1:30"` via `parse_time`), or `{timeout}`. Default 300s; values <0 or >900 clamp to 300""" # actions.c:258 + """Put the call on hold for N seconds. Accepts a number, a time string (`"5m"`, `"1:30"` via `parse_time`), or `{timeout}`. Default 300s; values <0 or >900 clamp to 300""" # actions.c:261 self.action.append({"hold": value}) # type: ignore[attr-defined] return self def playback_bg(self: _Self, value: str | PlaybackBgAction) -> _Self: - """Play an audio file in the background. `{wait:true}` makes the agent wait for it. Replaces any currently-open background file""" # actions.c:695 + """Play an audio file in the background. `{wait:true}` makes the agent wait for it. Replaces any currently-open background file""" # actions.c:698 self.action.append({"playback_bg": value}) # type: ignore[attr-defined] return self def replace_in_history(self: _Self, value: str | Literal[True]) -> _Self: - """Replace the function call's text in conversation history. A string is stored prefixed with `~LN()-; `; `true` stores an empty string""" # actions.c:379 + """Replace the function call's text in conversation history. A string is stored prefixed with `~LN()-; `; `true` stores an empty string""" # actions.c:382 self.action.append({"replace_in_history": value}) # type: ignore[attr-defined] return self def say(self: _Self, value: str) -> _Self: - """Speak text immediately via TTS, then wait for speaking to finish. Also logs `tl_manual_say`""" # actions.c:434 + """Speak text immediately via TTS, then wait for speaking to finish. Also logs `tl_manual_say`""" # actions.c:437 self.action.append({"say": value}) # type: ignore[attr-defined] return self def set_global_data(self: _Self, value: dict[str, Any]) -> _Self: - """Merge keys into global data, then refresh prompt vars. Gated by `swaig_set_global_data`""" # actions.c:498 + """Merge keys into global data, then refresh prompt vars. Gated by `swaig_set_global_data`""" # actions.c:501 self.action.append({"set_global_data": value}) # type: ignore[attr-defined] return self def set_meta_data(self: _Self, value: dict[str, Any]) -> _Self: - """Merge keys into the calling function's metadata store (keyed by its `meta_data_token`)""" # actions.c:459 + """Merge keys into the calling function's metadata store (keyed by its `meta_data_token`)""" # actions.c:462 self.action.append({"set_meta_data": value}) # type: ignore[attr-defined] return self def settings(self: _Self, value: dict[str, Any]) -> _Self: - """Modify LLM settings at runtime (`parse_json_settings`). Gated by `swaig_allow_settings`""" # actions.c:442 + """Modify LLM settings at runtime (`parse_json_settings`). Gated by `swaig_allow_settings`""" # actions.c:445 self.action.append({"settings": value}) # type: ignore[attr-defined] return self def speech_event_timeout(self: _Self, value: int) -> _Self: - """Set speech event timeout (must be >0)""" # actions.c:326 + """Set speech event timeout (must be >0)""" # actions.c:329 self.action.append({"speech_event_timeout": value}) # type: ignore[attr-defined] return self - def stop(self: _Self, value: dict[str, Any]) -> _Self: - """Stop the AI agent immediately (interrupt + `running = 0`)""" # actions.c:452 + def stop(self: _Self, value: bool | str) -> _Self: + """Stop the AI agent immediately (interrupt + `running = 0`)""" # actions.c:455 self.action.append({"stop": value}) # type: ignore[attr-defined] return self - def stop_playback_bg(self: _Self, value: dict[str, Any]) -> _Self: - """Stop/close the background audio file""" # actions.c:685 + def stop_playback_bg( + self: _Self, value: bool | str | int | dict[str, Any] | list[Any] | None + ) -> _Self: + """Stop/close the background audio file""" # actions.c:688 self.action.append({"stop_playback_bg": value}) # type: ignore[attr-defined] return self def toggle_functions(self: _Self, value: list[dict[str, Any]]) -> _Self: - """Enable/disable functions. `active` via `check_active`: `-1` default/toggle, `0` off, `1+` use-count. **Only affects functions sharing the calling function's `meta_data_token`** (`actions.c:419-420`)""" # actions.c:389 + """Enable/disable functions. `active` via `check_active`: `-1` default/toggle, `0` off, `1+` use-count. **Only affects functions sharing the calling function's `meta_data_token`** (`actions.c:419-420`)""" # actions.c:392 self.action.append({"toggle_functions": value}) # type: ignore[attr-defined] return self def transfer(self: _Self, value: str | TransferAction) -> _Self: - """Transfer the call to `dest`. `summarize:true` sets `transfer_summary`. Sets `openai_transfer_check` var, interrupts, stops the loop. Ignored if already interrupted""" # actions.c:136 + """Transfer the call to `dest`. `summarize:true` sets `transfer_summary`. Sets `openai_transfer_check` var, interrupts, stops the loop. Ignored if already interrupted""" # actions.c:343 self.action.append({"transfer": value}) # type: ignore[attr-defined] return self - def unset_global_data(self: _Self, value: str | list[Any]) -> _Self: - """Remove key(s) from global data, then refresh prompt vars. Gated by `swaig_set_global_data`""" # actions.c:515 + def unset_global_data(self: _Self, value: str | list[str]) -> _Self: + """Remove key(s) from global data, then refresh prompt vars. Gated by `swaig_set_global_data`""" # actions.c:518 self.action.append({"unset_global_data": value}) # type: ignore[attr-defined] return self - def unset_meta_data(self: _Self, value: str | list[Any]) -> _Self: - """Remove key(s) from the calling function's metadata store""" # actions.c:477 + def unset_meta_data(self: _Self, value: str | list[str]) -> _Self: + """Remove key(s) from the calling function's metadata store""" # actions.c:480 self.action.append({"unset_meta_data": value}) # type: ignore[attr-defined] return self def user_event(self: _Self, value: dict[str, Any]) -> _Self: - """Fire relay event `calling.user_event` with the object as payload""" # actions.c:231 + """Fire relay event `calling.user_event` with the object as payload""" # actions.c:234 self.action.append({"user_event": value}) # type: ignore[attr-defined] return self def user_input(self: _Self, value: str) -> _Self: - """Push text onto the input queue as if the user spoke it""" # actions.c:541 + """Push text onto the input queue as if the user spoke it""" # actions.c:544 self.action.append({"user_input": value}) # type: ignore[attr-defined] return self def wait_for_user( self: _Self, value: bool | int | Literal["answer_first"] ) -> _Self: - """`true` = `1`, a number sets a count, `"answer_first"` = `2` (require caller answer)""" # actions.c:300 + """`true` = `1`, a number sets a count, `"answer_first"` = `2` (require caller answer)""" # actions.c:303 self.action.append({"wait_for_user": value}) # type: ignore[attr-defined] return self diff --git a/signalwire/signalwire/core/swaig_request_generated.py b/signalwire/signalwire/core/swaig_request_generated.py index a8bb56c0..68563576 100644 --- a/signalwire/signalwire/core/swaig_request_generated.py +++ b/signalwire/signalwire/core/swaig_request_generated.py @@ -13,7 +13,7 @@ class SwaigArgument(TypedDict, total=False): """Open shape: extra server keys permitted; not validated at runtime.""" - parsed: list[Any] + parsed: list[dict[str, Any] | list[Any]] raw: str substituted: str @@ -21,13 +21,15 @@ class SwaigArgument(TypedDict, total=False): class SwaigRequest(TypedDict, total=False): """Open shape: extra server keys permitted; not validated at runtime.""" + SWMLCall: dict[str, Any] + SWMLVars: dict[str, Any] ai_session_id: str app_name: str args: str argument: SwaigArgument argument_desc: dict[str, Any] call_id: str - call_log: list[Any] + call_log: list[dict[str, Any]] caller_id_name: str caller_id_num: str channel_active: bool @@ -45,6 +47,6 @@ class SwaigRequest(TypedDict, total=False): meta_data: dict[str, Any] meta_data_token: str project_id: str - raw_call_log: list[Any] + raw_call_log: list[dict[str, Any]] space_id: str version: Literal["2.0"] diff --git a/signalwire/signalwire/core/swml_builder.py b/signalwire/signalwire/core/swml_builder.py index ae0dc30e..211be763 100644 --- a/signalwire/signalwire/core/swml_builder.py +++ b/signalwire/signalwire/core/swml_builder.py @@ -329,6 +329,33 @@ def sleep_method( def make_verb_method( name: str, ) -> Callable[..., "SWMLBuilder"]: + """ + Build the builder method for one SWML verb. + + The closure exists to bind ``name`` per verb — without it every + generated method would share the loop variable and emit the + last verb in the schema. + + The returned function takes only keyword arguments, drops every + kwarg whose value is None (so unset options never reach the + wire), passes the surviving dict to + ``service.add_verb(name, config)``, and returns the builder for + chaining. It carries the verb's schema ``description`` as its + ``__doc__`` when the schema supplies one. + + ``sleep`` is NOT built here — it takes a bare integer rather + than an object in SWML and is special-cased by the caller. + + Args: + name: The SWML verb name, used as both the emitted key and + the method name. + + Returns: + An unbound function of ``(self_instance, **kwargs) -> + SWMLBuilder``, which the caller binds with + ``types.MethodType`` and caches. + """ + def verb_method( self_instance: "SWMLBuilder", **kwargs: Any ) -> "SWMLBuilder": diff --git a/signalwire/signalwire/core/swml_handler.py b/signalwire/signalwire/core/swml_handler.py index e1c27f39..ab165c6a 100644 --- a/signalwire/signalwire/core/swml_handler.py +++ b/signalwire/signalwire/core/swml_handler.py @@ -121,6 +121,20 @@ def validate_config(self, config: dict[str, Any]) -> tuple[bool, list[str]]: if not isinstance(contexts, dict): errors.append("'prompt.contexts' must be an object") + # post_prompt is OPTIONAL, but when present the engine holds it to the + # SAME contract as prompt: mod_openai/app_config.c checks + # !cJSON_IsObject(assistant_prompt) at :3193 and !cJSON_IsObject(post_prompt) + # at :3219 -- same structure, same fatal:true calling.error, and both error + # payloads read "must be an object with 'text' or 'pom' field". Validating + # one and not the other reported configs VALID that abort the call on the + # wire; build_config has always emitted the right shape, so the hole was + # only reachable by a caller hand-assembling a config -- which is exactly + # how signalwire-go shipped a bare-string post_prompt (go 51934ec). + if "post_prompt" in config: + post_prompt = config["post_prompt"] + if not isinstance(post_prompt, dict): + errors.append("'post_prompt' must be an object") + # Validate SWAIG structure if present if "SWAIG" in config: swaig = config["SWAIG"] diff --git a/signalwire/signalwire/core/swml_renderer.py b/signalwire/signalwire/core/swml_renderer.py index 78b3aec1..42644c43 100644 --- a/signalwire/signalwire/core/swml_renderer.py +++ b/signalwire/signalwire/core/swml_renderer.py @@ -141,9 +141,9 @@ def render_swml( if format.lower() == "yaml": import yaml - # yaml has no type stubs (ignore_missing_imports), so dump() is Any; - # with no stream argument it returns the serialized str. - return cast(str, yaml.dump(builder.build(), sort_keys=False)) + # types-PyYAML (declared in requirements-dev.txt) types the no-stream + # overload of dump() as -> str, so no cast is needed. + return yaml.dump(builder.build(), sort_keys=False) return builder.render() @staticmethod @@ -193,6 +193,6 @@ def render_function_response_swml( if format.lower() == "yaml": import yaml - # yaml.dump() is Any (no stubs); returns the serialized str. - return cast(str, yaml.dump(service.get_document(), sort_keys=False)) + # types-PyYAML types the no-stream overload of dump() as -> str. + return yaml.dump(service.get_document(), sort_keys=False) return service.render_document() diff --git a/signalwire/signalwire/core/swml_service.py b/signalwire/signalwire/core/swml_service.py index 82999ccf..efac81f8 100644 --- a/signalwire/signalwire/core/swml_service.py +++ b/signalwire/signalwire/core/swml_service.py @@ -273,6 +273,36 @@ def sleep_method( # Generate the method implementation for normal verbs def make_verb_method(name: str) -> Callable[..., bool]: + """ + Build the service method for one SWML verb. + + The closure binds ``name`` per verb; without it every generated + method would close over the shared loop variable and emit the + last verb in the schema. + + The returned function takes keyword arguments only, drops every + kwarg whose value is None (so unset options never reach the + wire), and calls ``add_verb(name, config)`` — returning that + call's bool, i.e. **False when the verb fails schema + validation** rather than raising. It carries the verb's schema + ``description`` as its ``__doc__`` when the schema has one. + + This differs from the ``SWMLBuilder`` method of the same name, + which returns the builder for chaining instead of a bool. + + ``sleep`` is NOT built here — it takes a bare integer rather + than an object in SWML and is special-cased by the caller. + + Args: + name: The SWML verb name, used as both the emitted key and + the method name. + + Returns: + An unbound function of ``(self_instance, **kwargs) -> + bool``, which the caller binds with ``types.MethodType`` + and caches in ``_verb_methods_cache``. + """ + def verb_method(self_instance: "SWMLService", **kwargs: Any) -> bool: """ Dynamically generated method for SWML verb @@ -689,6 +719,31 @@ async def handle_root(request: Request, response: Response) -> Response: @router.post("/swaig") @router.post("/swaig/") async def handle_swaig(request: Request, response: Response) -> Response: + """Serve the ``/swaig`` endpoint (all four slash/method variants). + + Delegates to ``_handle_swaig_request`` and coerces its result + through ``_as_response`` (that method may hand back a bare dict — + its historical contract — which FastAPI route handlers cannot + declare, so dicts become a ``JSONResponse``). + + The underlying handler is basic-auth-gated and answers: + + - GET → the SWML document, via ``_swaig_render_get_response`` + (``call_id`` may be passed as a query param). + - POST → dispatch of the named SWAIG function from the JSON body's + ``function`` / ``argument`` / ``call_id`` fields, returning the + ``FunctionResult``-shaped payload. + + Failure statuses come from that handler: 401 with + ``WWW-Authenticate: Basic`` when auth fails, 415 for a non-JSON + Content-Type, 413 for an oversized body, and 400 for a missing + ``function`` or a name that is not a bare identifier. + + This endpoint is registered by ``SWMLService.as_router()``, so it + is available on ANY SWMLService, not just AgentBase — AgentBase + layers its extra behaviour on by overriding the handler's + extension points rather than by adding the route. + """ return _as_response(await self._handle_swaig_request(request, response)) # Register routing callbacks as needed @@ -759,6 +814,35 @@ async def _swaig_render_get_response( """ return Response(content=self.render_document(), media_type="application/json") + def _swaig_validate_token( + self, + function_name: str, + token: str | None, + call_id: str | None, + ) -> dict[str, Any] | None: + """Extension point: transport-agnostic `secure=True` token enforcement. + + This is the SOLE security decision for a SWAIG call, deliberately kept + free of any request/transport type so that EVERY transport -- the HTTP + endpoint and all four serverless modes (lambda, cgi, + google_cloud_function, azure_function) -- reaches the identical check + with the identical semantics. Each transport is responsible only for + EXTRACTING the credential from its own payload shape; none of them + re-implements the decision. + + Args: + function_name: The SWAIG function being invoked. + token: The `__token` credential, or None when absent. + call_id: The call the token must be bound to, or None when absent. + + Returns: + None to proceed with dispatch, or a FunctionResult-shaped dict to + return INSTEAD of dispatching (the refusal). The refusal is always + delivered as a 200 + FunctionResult body, never an HTTP error + status -- the engine has no handling for a SWAIG refusal status. + """ + return None + def _swaig_pre_dispatch( self, request: Request, @@ -1240,13 +1324,35 @@ def serve( ssl_cert_path = ssl_cert or getattr(self, "ssl_cert_path", None) ssl_key_path = ssl_key or getattr(self, "ssl_key_path", None) - # Validate SSL configuration if enabled + # Validate SSL configuration if enabled. + # + # TLS that cannot be configured is a FATAL misconfiguration, never a + # silent downgrade: the operator asked for encryption, and starting a + # cleartext listener instead would ship their traffic — including the + # credentials carried in Basic auth — in the clear, with no error and + # no way to notice. Refuse to start. if self.ssl_enabled: - is_valid, error = self.security.validate_ssl_config() - if not is_valid: - self.log.warning("ssl_config_invalid", error=error) - self.ssl_enabled = False - elif not self.domain: + # Validate the paths that will actually reach uvicorn: they may + # come from the serve(ssl_cert=/ssl_key=) arguments, which the + # security config has never seen. + error: str | None = None + if not ssl_cert_path: + error = "SSL enabled but no certificate path configured" + elif not Path(ssl_cert_path).exists(): + error = f"SSL certificate file not found: {ssl_cert_path}" + elif not ssl_key_path: + error = "SSL enabled but no private key path configured" + elif not Path(ssl_key_path).exists(): + error = f"SSL key file not found: {ssl_key_path}" + if error is not None: + self.log.error("ssl_config_invalid", error=error) + raise RuntimeError( + f"SSL is enabled but the TLS configuration is invalid: {error}. " + f"Refusing to start a plaintext listener when TLS was " + f"requested — fix SWML_SSL_CERT_PATH / SWML_SSL_KEY_PATH, or " + f"disable SSL to serve plain HTTP deliberately." + ) + if not self.domain: self.log.warning("ssl_domain_not_specified") # We'll continue, but URLs might not be correctly generated @@ -1272,6 +1378,37 @@ def serve( async def handle_all_routes( request: Request, response: Response, full_path: str ) -> Response: + """Catch-all that accepts this service's route with or without + a trailing slash. + + The router is mounted under a prefix normalized to have no + trailing slash, and the app is built with + ``redirect_slashes=False``, so ``//`` and its subpaths + would otherwise 404. This handler recovers them: + + - ``full_path`` exactly equal to the route → handled as root. + - ``full_path`` equal to ``/`` → handled as root. + - ``full_path`` under ``/`` whose remainder matches a + registered routing callback (exactly, or as its parent + segment) → that callback path is stashed on + ``request.state.callback_path`` and the request is handled as + root. + + Anything else returns ``JSONResponse({"error": "Path not + found"})`` — note this is a **200**, not a 404: the status code + is left at FastAPI's default, so a caller must inspect the body + to detect a miss. + + Args: + request: The incoming request. + response: FastAPI-injected response object, forwarded to + ``_handle_request``. + full_path: The matched path with no leading slash. + + Returns: + The service's SWML/handler response, or the error body + above. + """ # Get our route path without leading slash for comparison route_path = normalized_route.lstrip("/") route_with_slash = route_path + "/" diff --git a/signalwire/signalwire/livewire/__init__.py b/signalwire/signalwire/livewire/__init__.py index 49e57919..2b4e1475 100644 --- a/signalwire/signalwire/livewire/__init__.py +++ b/signalwire/signalwire/livewire/__init__.py @@ -114,10 +114,24 @@ def once(self, key: str, message: str) -> bool: return True def was_logged(self, key: str) -> bool: + """Report whether ``once()`` has already emitted the message for *key*. + + Args: + key: The de-duplication key passed to ``once()``. + + Returns: + True if a message has been logged under *key*, False otherwise. + Does not itself log anything. + """ with self._lock: return self._logged.get(key, False) def reset(self) -> None: + """Forget every key already logged, so ``once()`` will emit again. + + Used mainly by tests, which share the module-level tracker and would + otherwise see a message suppressed because an earlier test triggered it. + """ with self._lock: self._logged.clear() @@ -162,6 +176,23 @@ def __init__(self) -> None: self.messages: list[dict[str, str]] = [] def append(self, *, role: str = "user", text: str = "") -> "ChatContext": + """Append a message to the context, mirroring livekit ``ChatContext.append``. + + The message is stored as ``{"role": role, "content": text}`` -- note the + *text* keyword lands under the ``content`` key, matching the OpenAI-style + chat shape rather than the argument name. + + On SignalWire nothing is sent to the platform from here: prompt content + is carried by ``Agent(instructions=...)``, so this context is a + conversation buffer the caller can read back from ``messages``. + + Args: + role: Speaker label for the message, e.g. ``"user"`` or ``"assistant"``. + text: Message body. + + Returns: + self, so appends can be chained. + """ self.messages.append({"role": role, "content": text}) return self @@ -269,6 +300,17 @@ def __init__( @property def userdata(self) -> Any: + """The bound session's ``userdata``, as livekit exposes it to tool handlers. + + Reads through to ``self.session.userdata`` rather than holding its own + copy, so a tool sees whatever the session currently carries. + + Returns: + The session's userdata object, or an empty dict when no session is + attached. Tool handlers built by ``_register_function_tool`` are + currently constructed with ``RunContext(session=None)``, so they take + the empty-dict path -- a fresh dict each access, not shared state. + """ if self.session is not None: return self.session.userdata return {} @@ -344,10 +386,25 @@ def __init__( @property def session(self) -> Optional["AgentSession"]: + """The AgentSession this agent is bound to, or None before binding. + + Returns: + The session set by ``AgentSession.start()`` / ``update_agent()``. + It is None on a freshly constructed Agent -- lifecycle hooks such as + ``on_enter`` must not assume it is populated unless a session has + started. + """ return self._session @session.setter def session(self, value: "AgentSession | None") -> None: + """Bind this agent to *value*, or clear the binding when None. + + Assignment only records the back-reference; it does not register the + agent with the session. The session owns that direction and sets this + itself from ``start()`` and ``update_agent()``, so callers rarely assign + it directly. + """ self._session = value # ------------------------------------------------------------------ @@ -503,14 +560,40 @@ def __init__( @property def userdata(self) -> Any: + """Arbitrary per-session state, as livekit's ``AgentSession.userdata``. + + Set from the ``userdata=`` constructor argument, defaulting to an empty + dict when that argument is None or omitted. The SDK never reads or + interprets it -- it is a caller-owned slot carried alongside the session, + and it is not transmitted to the SignalWire platform. + + Returns: + Whatever object the caller stored; an empty dict by default. + """ return self._userdata @userdata.setter def userdata(self, val: Any) -> None: + """Replace the session's user state wholesale with *val*. + + Any object is accepted -- there is no type or shape check, and no + merging with the previous value. ``RunContext.userdata`` reads through + to this attribute, so a tool handler holding a RunContext bound to this + session observes the new value on its next access. + """ self._userdata = val @property def history(self) -> list[dict[str, str]]: + """The session's transcript buffer, mirroring livekit's ``history``. + + Returns: + The live list backing the session, in ``{"role": ..., "content": ...}`` + form. It is created empty and, on SignalWire, stays empty: the + platform's control plane owns the conversation, and nothing in this + module appends to it. Callers may append to the returned list + themselves, but the SDK never populates or reads it. + """ return self._history # ------------------------------------------------------------------ @@ -626,6 +709,29 @@ def _register_function_tool(sw_agent: Any, fn: "Callable[..., Any]") -> None: def handler( args: dict[str, Any], raw_data: dict[str, Any] | None = None ) -> "FunctionResult": + """Adapt a SWAIG tool invocation onto the wrapped LiveKit-style function. + + This is the bridge between the two calling conventions: SignalWire's + ``define_tool`` hands the handler a flat ``args`` dict, while the + decorated function expects named Python parameters. Each parameter of + *fn* is resolved in turn -- ``self`` is skipped, a parameter annotated as + a ``RunContext`` receives a fresh ``RunContext(session=None)``, otherwise + the value comes from *args* or, when absent there, from the parameter's + own default. A parameter that is in neither is left unbound, so *fn* + raises ``TypeError`` for a genuinely missing required argument. + + Args: + args: Tool arguments parsed by SignalWire from the LLM's call. + raw_data: Full SWAIG POST body. Accepted for signature compatibility + with ``define_tool`` and currently unused -- it is not forwarded + to *fn* and not exposed through the RunContext. + + Returns: + The function's return value wrapped in a ``FunctionResult``: a str + is passed through as-is, anything else is rendered with ``str()``. + *fn* is always called synchronously, so a coroutine function would be + stringified as an un-awaited coroutine rather than executed. + """ from signalwire.core.function_result import FunctionResult sig = inspect.signature(fn) diff --git a/signalwire/signalwire/mcp_gateway/gateway_service.py b/signalwire/signalwire/mcp_gateway/gateway_service.py index f0728098..d1ba1591 100644 --- a/signalwire/signalwire/mcp_gateway/gateway_service.py +++ b/signalwire/signalwire/mcp_gateway/gateway_service.py @@ -27,10 +27,16 @@ import ssl import concurrent.futures -from flask import Flask, request, jsonify, Response -from werkzeug.serving import make_server, BaseWSGIServer -from flask_limiter import Limiter -from flask_limiter.util import get_remote_address +try: + from flask import Flask, request, jsonify, Response + from werkzeug.serving import make_server, BaseWSGIServer + from flask_limiter import Limiter + from flask_limiter.util import get_remote_address +except ImportError: # pragma: no cover - exercised with the extra simulated absent + raise ImportError( + "flask and flask-limiter are required for the MCP gateway. " + "Install them with: pip install signalwire-sdk[mcp-gateway]" + ) from None from functools import wraps import threading @@ -68,7 +74,22 @@ def __init__(self, config_path: str = "config.json") -> None: self.security = SecurityConfig(config_file=config_path, service_name="mcp") self.security.log_config("MCPGateway") - self.app = Flask(__name__) + # `app`/`limiter` are deliberately typed `Any`, not `Flask`/`Limiter`. + # + # flask and flask-limiter are the OPTIONAL `mcp-gateway` extra, and they + # ship `py.typed`. That makes their decorators' types depend on whether + # the extra happens to be INSTALLED, which flips this file's findings + # both ways and cannot be settled with `# type: ignore`: + # * extra absent -> `@self.app.route` is Any, so every decorated + # handler is [untyped-decorator] unless it carries an ignore; + # * extra present -> the decorators are typed, so those same ignores + # become [unused-ignore] under warn_unused_ignores. + # Pinning the two attributes to Any resolves the decorators identically + # in both worlds, so this file type-checks the same way with or without + # the extra — including for SDK CONSUMERS running mypy over their own + # code with their own config, who get no say in our mypy settings and + # would otherwise inherit whichever error our ignores did not cover. + self.app: Any = Flask(__name__) self.mcp_manager = MCPManager(self.config) self.session_manager = SessionManager(self.config) self.server: BaseWSGIServer | None = None @@ -82,16 +103,43 @@ def __init__(self, config_path: str = "config.json") -> None: ) storage_uri = self.rate_config.get("storage_uri", "memory://") - self.limiter = Limiter( + self.limiter: Any = Limiter( app=self.app, key_func=get_remote_address, default_limits=default_limits, storage_uri=storage_uri, ) - # Configure security headers - @self.app.after_request # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator - def set_security_headers(response: Response) -> Response: + # Configure security headers. + # `response` is annotated Any rather than `Response` for the same reason + # `self.app` is: with the extra installed `Response` is a real type and + # this decorator resolves as typed, without it everything is Any. Any on + # both sides keeps the result identical in both worlds. + def set_security_headers(response: Any) -> Any: + """Attach security headers to every gateway response. + + Registered as a Flask ``after_request`` hook, so it applies to + error responses too. + + Sets ``X-Content-Type-Options: nosniff``, + ``X-Frame-Options: DENY``, ``X-XSS-Protection: 1; mode=block`` and + a ``Content-Security-Policy`` of + ``default-src 'none'; frame-ancestors 'none';`` — the gateway + serves JSON only, so the policy blocks every subresource rather + than allow-listing any. On a secure request it also sets + ``Strict-Transport-Security: max-age=31536000; includeSubDomains``. + + Note this is the gateway's own hardcoded header set, independent of + ``SecurityConfig.get_security_headers`` used by the FastAPI + services: it adds CSP, omits ``Referrer-Policy``, and its HSTS is + not configurable. + + Args: + response: The outgoing Flask response. + + Returns: + The same response, mutated in place. + """ response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["X-XSS-Protection"] = "1; mode=block" @@ -104,6 +152,14 @@ def set_security_headers(response: Response) -> Response: ) return response + # Registered by CALL rather than with `@self.app.after_request` on + # purpose: as a decorator it rewrites the function's type, and mypy then + # reports [untyped-decorator] when the optional extra is absent but not + # when it is present — the one construct that could not be made to agree + # in both worlds. Calling it registers the hook identically at runtime + # while leaving the function's own (fully annotated) type alone. + self.app.after_request(set_security_headers) + # Configure request size limit (10MB) self.app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 @@ -286,6 +342,31 @@ def _check_auth(self, f: Callable[..., Any]) -> Callable[..., Any]: @wraps(f) def decorated(*args: Any, **kwargs: Any) -> Any: + """Authenticate the current request, then call the wrapped view. + + The inner wrapper produced by ``_check_auth``; ``functools.wraps`` + keeps the view's name so Flask's routing table is unaffected. + Args and kwargs are the view's own (e.g. URL path params) and are + forwarded untouched — the credentials come from the Flask request + context, not the arguments. + + Accepts either scheme, Bearer first: an ``Authorization: Bearer`` + token compared against ``server.auth_token``, else HTTP Basic + compared against ``server.auth_user`` / ``server.auth_password``. + Both comparisons use ``hmac.compare_digest`` to avoid leaking the + secret through timing. A Bearer header that fails falls through to + the Basic check rather than rejecting outright. + + On failure it logs an ``auth_failed`` security event carrying the + client IP, method and path, and returns ``401`` with + ``WWW-Authenticate: Basic realm="MCP Gateway"`` — the view is never + invoked. Note an unconfigured ``auth_token`` disables Bearer + (the empty expected value is skipped), so Basic remains the path. + + Returns: + The wrapped view's return value on success, otherwise a 401 + Flask Response. + """ # Try Bearer token first auth_header = request.headers.get("Authorization", "") server_config = self.config.get("server", {}) @@ -331,7 +412,7 @@ def decorated(*args: Any, **kwargs: Any) -> Any: def _setup_routes(self) -> None: """Set up Flask routes""" - @self.app.route("/health", methods=["GET"]) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator + @self.app.route("/health", methods=["GET"]) # type: ignore[untyped-decorator] # app/limiter pinned to Any above -> Any decorator def health() -> Any: """Health check endpoint""" return jsonify( @@ -342,15 +423,15 @@ def health() -> Any: } ) - @self.app.route("/services", methods=["GET"]) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator + @self.app.route("/services", methods=["GET"]) # type: ignore[untyped-decorator] # app/limiter pinned to Any above -> Any decorator @self._check_auth def list_services() -> Any: """List available MCP services""" services = self.mcp_manager.list_services() return jsonify(services) - @self.app.route("/services//tools", methods=["GET"]) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator - @self.limiter.limit(self.rate_config.get("tools_limit", "30 per minute")) # type: ignore[untyped-decorator] # flask-limiter is an optional extra with no stubs installed -> Any decorator + @self.app.route("/services//tools", methods=["GET"]) # type: ignore[untyped-decorator] # app/limiter pinned to Any above -> Any decorator + @self.limiter.limit(self.rate_config.get("tools_limit", "30 per minute")) # type: ignore[untyped-decorator] # app/limiter pinned to Any above -> Any decorator @self._check_auth def get_service_tools(service_name: str) -> Any: """Get tools for a specific service""" @@ -366,8 +447,8 @@ def get_service_tools(service_name: str) -> Any: logger.error(f"Error getting tools for {service_name}: {e}") return jsonify({"error": "Service error"}), 500 - @self.app.route("/services//call", methods=["POST"]) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator - @self.limiter.limit(self.rate_config.get("call_limit", "10 per minute")) # type: ignore[untyped-decorator] # flask-limiter is an optional extra with no stubs installed -> Any decorator + @self.app.route("/services//call", methods=["POST"]) # type: ignore[untyped-decorator] # app/limiter pinned to Any above -> Any decorator + @self.limiter.limit(self.rate_config.get("call_limit", "10 per minute")) # type: ignore[untyped-decorator] # app/limiter pinned to Any above -> Any decorator @self._check_auth def call_service_tool(service_name: str) -> Any: """Call a tool on a service""" @@ -482,15 +563,15 @@ def call_service_tool(service_name: str) -> Any: logger.error(f"Error calling tool: {e}") return jsonify({"error": str(e)}), 500 - @self.app.route("/sessions", methods=["GET"]) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator + @self.app.route("/sessions", methods=["GET"]) # type: ignore[untyped-decorator] # app/limiter pinned to Any above -> Any decorator @self._check_auth def list_sessions() -> Any: """List active sessions""" sessions = self.session_manager.list_sessions() return jsonify(sessions) - @self.app.route("/sessions/", methods=["DELETE"]) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator - @self.limiter.limit( # type: ignore[untyped-decorator] # flask-limiter is an optional extra with no stubs installed -> Any decorator + @self.app.route("/sessions/", methods=["DELETE"]) # type: ignore[untyped-decorator] # app/limiter pinned to Any above -> Any decorator + @self.limiter.limit( # type: ignore[untyped-decorator] # app/limiter pinned to Any above -> Any decorator self.rate_config.get("session_delete_limit", "20 per minute") ) @self._check_auth @@ -510,8 +591,26 @@ def close_session(session_id: str) -> Any: except ValueError as e: return jsonify({"error": str(e)}), 400 - @self.app.errorhandler(Exception) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator + @self.app.errorhandler(Exception) # type: ignore[untyped-decorator] # app/limiter pinned to Any above -> Any decorator def handle_error(error: Exception) -> Any: + """Convert any unhandled exception into a generic 500 JSON error. + + Registered as the Flask ``errorhandler(Exception)`` catch-all, so + it is the last resort for exceptions no route handled itself. + + Deliberately opaque to the client: the exception is logged + server-side at ERROR level, but the response body is the fixed + ``{"error": "Internal server error"}`` with status 500 — the + exception text, type and traceback never reach the caller. Routes + that want a specific message must catch and return it themselves + (as the session routes do for ``ValueError`` → 400). + + Args: + error: The unhandled exception. + + Returns: + A ``(JSON response, 500)`` tuple. + """ logger.error(f"Unhandled error: {error}") return jsonify({"error": "Internal server error"}), 500 diff --git a/signalwire/signalwire/pom/pom.py b/signalwire/signalwire/pom/pom.py index 8f612422..b5a34234 100644 --- a/signalwire/signalwire/pom/pom.py +++ b/signalwire/signalwire/pom/pom.py @@ -1,4 +1,4 @@ -from typing import Any, cast +from typing import Any import json import yaml @@ -332,6 +332,31 @@ def _from_dict(data: str | dict[str, Any] | list[Any]) -> "PromptObjectModel": """ def build_section(d: dict[str, Any], is_subsection: bool = False) -> Section: + """Validate one section dict and build the Section tree beneath it. + + Recurses into ``subsections``, so one call on a top-level dict + returns that whole branch already constructed. + + Type-checks ``title`` (str), ``subsections`` (list), ``bullets`` + (list), ``numbered`` and ``numberedBullets`` (bool) when present, + then enforces the two content rules: every section must carry a + non-empty ``body``, non-empty ``bullets``, or ``subsections``, and + every subsection must have a ``title``. ``numbered`` and + ``numberedBullets`` are forwarded only when explicitly present in + the dict, so Section's own defaults survive their absence. + + Args: + d: One section's parsed dict, from JSON or YAML. + is_subsection: True when called for a nested section, which + turns on the mandatory-title rule. + + Returns: + The Section, with its subsections already appended. + + Raises: + ValueError: On a non-dict, a field of the wrong type, a + section with no content, or an untitled subsection. + """ if not isinstance(d, dict): raise ValueError("Each section must be a dictionary.") if "title" in d and not isinstance(d["title"], str): @@ -449,6 +474,20 @@ def find_section(self, title: str) -> Section | None: """ def recurse(sections: list[Section]) -> Section | None: + """Depth-first search of a section list for a matching title. + + Walks each section, descending into its subsections before moving + to the next sibling, so the first match in document order wins. + Matching is exact string equality on ``Section.title``; a section + with no title never matches. + + Args: + sections: The sections to search, with their subsections. + + Returns: + The first matching Section, or None if the whole subtree + contains no section with that title. + """ for section in sections: if section.title == title: return section @@ -475,15 +514,12 @@ def to_yaml(self) -> str: Returns: A YAML string representation of the model """ - # yaml has no type stubs (ignore_missing_imports) so dump() is Any; with - # no stream argument it returns the serialized str. - return cast( - str, - yaml.dump( - [s.to_dict() for s in self.sections], - default_flow_style=False, - sort_keys=False, - ), + # types-PyYAML (declared in requirements-dev.txt) types the no-stream + # overload of dump() as -> str, so no cast is needed. + return yaml.dump( + [s.to_dict() for s in self.sections], + default_flow_style=False, + sort_keys=False, ) def to_dict(self) -> list[dict[str, Any]]: diff --git a/signalwire/signalwire/relay/call.py b/signalwire/signalwire/relay/call.py index 579fcd3c..ae0eb695 100644 --- a/signalwire/signalwire/relay/call.py +++ b/signalwire/signalwire/relay/call.py @@ -125,6 +125,16 @@ async def wait(self, timeout: float | None = None) -> RelayEvent: @property def is_done(self) -> bool: + """Whether the terminal event for this action has already arrived. + + A non-blocking poll of the completion future — ``True`` once the server + reported one of this action's terminal states, so ``wait()`` would + return immediately. Use it to check completion without awaiting. + + Note this reflects the *future*, not the ``completed`` flag: they move + together in ``_resolve``, but ``is_done`` is the authoritative signal + for whether ``wait()`` blocks. + """ return self._done.done() @@ -136,6 +146,23 @@ class StoppableAction(Action): _command_prefix: str = "" async def stop(self) -> dict[str, Any]: + """Stop this in-flight operation on the server. + + Posts ``calling..stop`` with this action's ``control_id``, + where ```` is the concrete subclass's command family + (``play``, ``record``, ``detect``, ``collect``, ``tap``, ``stream``, + ``pay``, ``transcribe``, ``ai``, or the fax direction). + + The command only asks the server to stop; it does not itself mark the + action complete. Completion still arrives as the operation's terminal + event, so ``await action.wait()`` after ``stop()`` to observe the final + state. Requires the call to still be alive and the operation not yet + finished — stopping an already-finished operation is answered by the + server, not guarded locally. + + Returns: + The RELAY command result dict. + """ return await self.call._execute( f"{self._command_prefix}.stop", {"control_id": self.control_id} ) @@ -145,12 +172,39 @@ class PausableAction(StoppableAction): """A stoppable action that can also pause/resume (record, play, collect).""" async def pause(self, behavior: str | None = None) -> dict[str, Any]: + """Pause this operation, leaving it resumable. + + Posts ``calling..pause`` with this action's ``control_id``. + Unlike :meth:`stop` the operation stays alive and holds its control_id, + so :meth:`resume` picks it back up and the terminal event still comes + later. + + Args: + behavior: Only meaningful for a recording, where the engine accepts + ``"skip"`` (omit the paused span from the recording) or + ``"silence"`` (write silence for its duration). Omitted from + the wire params when falsy, letting the server default apply. + The play/collect pause commands carry no ``behavior`` field. + + Returns: + The RELAY command result dict. + """ params: dict[str, Any] = {"control_id": self.control_id} if behavior: params["behavior"] = behavior return await self.call._execute(f"{self._command_prefix}.pause", params) async def resume(self) -> dict[str, Any]: + """Resume this operation after :meth:`pause`. + + Posts ``calling..resume`` with this action's ``control_id``. + Takes no options — a paused recording resumes under whatever + ``behavior`` the pause selected. Requires the operation to be paused + and its control_id still valid on the call. + + Returns: + The RELAY command result dict. + """ return await self.call._execute( f"{self._command_prefix}.resume", {"control_id": self.control_id} ) @@ -160,6 +214,20 @@ class VolumeAction(PausableAction): """A pausable action that also supports a volume adjustment (play).""" async def volume(self, volume: float) -> dict[str, Any]: + """Adjust the playback gain of this in-flight operation. + + Posts ``calling..volume`` with this action's ``control_id``. + Takes effect on the audio still to be played; it does not restart or + reposition the media. + + Args: + volume: Gain in **decibels**, not a 0-to-1 multiplier. ``0`` is + unmodified; negative attenuates, positive amplifies. The engine + requires the value and rejects anything outside -40 to +40 dB. + + Returns: + The RELAY command result dict. + """ return await self.call._execute( f"{self._command_prefix}.volume", {"control_id": self.control_id, "volume": volume}, @@ -776,6 +844,14 @@ async def _wait_for_state(self, target: str, timeout: float | None) -> RelayEven ) def rank(s: str) -> int: + """Position of a call state in the lifecycle order. + + Maps ``created`` → ``ringing`` → ``answered`` → ``ending`` → + ``ended`` onto 0-4 so the two states can be compared with ``>=``. + An unrecognized or empty state returns ``-1``, which sorts before + every real state — so a call whose state is not yet known never + counts as having reached the target and the caller waits. + """ return order.index(s) if s in order else -1 # Already at or past the target -> return immediately (matches legacy SDK). diff --git a/signalwire/signalwire/relay/client.py b/signalwire/signalwire/relay/client.py index 6dc4252e..cee34d7e 100644 --- a/signalwire/signalwire/relay/client.py +++ b/signalwire/signalwire/relay/client.py @@ -24,6 +24,7 @@ import json import os import re +import signal import ssl as ssl_module import uuid from typing import Any, TYPE_CHECKING @@ -92,16 +93,25 @@ _DEFAULT_MAX_ACTIVE_CALLS = 1000 _MAX_QUEUE_SIZE = 500 -# Max concurrent RelayClient connections per process (env: RELAY_MAX_CONNECTIONS) -try: - _MAX_CONNECTIONS = max(1, int(os.environ.get("RELAY_MAX_CONNECTIONS", "1"))) -except ValueError: - _MAX_CONNECTIONS = 1 - # Process-wide tracking of active RelayClient connections _active_clients: set[int] = set() +def _max_connections() -> int: + """Max concurrent RelayClient connections per process. + + Read from ``RELAY_MAX_CONNECTIONS`` **at connection time**, never cached. + ``connect()``'s refusal message tells the operator to set this variable; + caching it at import made that advice impossible to follow, because by the + time the message is seen the module is already imported. Defaults to 1; + a non-integer or sub-1 value falls back to 1. + """ + try: + return max(1, int(os.environ.get("RELAY_MAX_CONNECTIONS", "1"))) + except ValueError: + return 1 + + # Credential-bearing JSON keys whose VALUES must never appear in debug logs # (SECRET-SCRUB, A6/enterprise): the raw RELAY frames carry the connect # authentication (project/token/jwt_token) and the server's encrypted @@ -342,9 +352,10 @@ async def connect(self) -> None: # Guard against connection leaks — enforce per-process limit # (don't count ourselves if we're already tracked, i.e. reconnecting) other_count = len(_active_clients - {id(self)}) - if other_count >= _MAX_CONNECTIONS: + max_connections = _max_connections() + if other_count >= max_connections: raise RuntimeError( - f"RelayClient connection limit reached ({_MAX_CONNECTIONS}). " + f"RelayClient connection limit reached ({max_connections}). " f"There are already {other_count} active connection(s) in this process. " f"Call disconnect() on existing clients first, or set " f"RELAY_MAX_CONNECTIONS env var to allow more." @@ -680,11 +691,27 @@ async def _run_forever(self) -> None: """Connect and maintain the connection with auto-reconnect.""" # Register SIGINT handler so Ctrl+C triggers a clean shutdown # instead of dumping a stack trace. + # + # loop.add_signal_handler() is a Unix-only asyncio capability: the + # Windows Proactor/Selector loops raise NotImplementedError + # unconditionally (CPython Lib/asyncio/events.py). Without this guard a + # bare `NotImplementedError` escaped _run_forever() on the very first + # statement, so RelayClient.run() could never connect on Windows at + # all. Degrade instead: on a platform with no loop-level signal + # handling, Ctrl+C still stops the client — asyncio.run() surfaces it + # as KeyboardInterrupt, which run() suppresses — we just lose the + # graceful _shutdown() handshake. loop = asyncio.get_running_loop() - loop.add_signal_handler( - __import__("signal").SIGINT, - lambda: asyncio.ensure_future(self._shutdown()), - ) + try: + loop.add_signal_handler( + signal.SIGINT, + lambda: asyncio.ensure_future(self._shutdown()), + ) + except NotImplementedError: + logger.debug( + "Loop-level SIGINT handling unavailable on this platform; " + "falling back to KeyboardInterrupt-driven shutdown" + ) while not self._closing: try: diff --git a/signalwire/signalwire/relay/event.py b/signalwire/signalwire/relay/event.py index d9c1d683..fd63871f 100644 --- a/signalwire/signalwire/relay/event.py +++ b/signalwire/signalwire/relay/event.py @@ -21,6 +21,18 @@ class RelayEvent: @classmethod def from_payload(cls, payload: dict[str, Any]) -> RelayEvent: + """Build a RelayEvent from a raw ``signalwire.event`` message payload. + + Reads ``event_type`` from the top level and ``call_id``/``timestamp`` out of + the nested ``params`` object, keeping the whole ``params`` dict so callers can + reach fields this wrapper does not model. + + Args: + payload: The raw event payload dict from the RELAY WebSocket message. + + Returns: + A RelayEvent carrying the event type, the raw params and the call id. + """ event_type = payload.get("event_type", "") params = payload.get("params", {}) return cls( @@ -42,6 +54,18 @@ class CallStateEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> CallStateEvent: + """Build a CallStateEvent from a ``calling.call.state`` payload. + + Adds the call's lifecycle fields on top of the base event: ``call_state`` + (created/ringing/answered/ending/ended), ``end_reason``, ``direction`` and the + ``device`` object describing the endpoint. + + Args: + payload: The raw event payload dict. + + Returns: + A CallStateEvent with the state fields extracted from ``params``. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -71,6 +95,22 @@ class CallReceiveEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> CallReceiveEvent: + """Build a CallReceiveEvent from a ``calling.call.receive`` payload. + + This is the inbound-call notification, so it carries the routing identity a + handler needs to decide whether to answer: ``node_id``, ``project_id``, + ``context``, ``segment_id`` and ``tag``, alongside the call's ``call_state``, + ``direction`` and ``device``. + + ``context`` falls back to the payload's ``protocol`` field when ``context`` is + absent, because older RELAY servers name the same value ``protocol``. + + Args: + payload: The raw event payload dict. + + Returns: + A CallReceiveEvent describing the inbound call. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -98,6 +138,18 @@ class PlayEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> PlayEvent: + """Build a PlayEvent from a ``calling.call.play`` payload. + + Carries the ``control_id`` identifying the play operation and its ``state`` + (playing/paused/finished/error), which is how a caller correlates the event + with the play it started. + + Args: + payload: The raw event payload dict. + + Returns: + A PlayEvent for the play operation named by ``control_id``. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -123,6 +175,20 @@ class RecordEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> RecordEvent: + """Build a RecordEvent from a ``calling.call.record`` payload. + + The recording's ``url``, ``duration`` and ``size`` are read from the nested + ``record`` object when present and from the top level of ``params`` otherwise, + since RELAY reports them in either position depending on the event stage. The + whole ``record`` object is kept on the event so callers can read fields this + wrapper does not model. + + Args: + payload: The raw event payload dict. + + Returns: + A RecordEvent with the recording metadata and its ``state``. + """ base = RelayEvent.from_payload(payload) p = base.params rec = p.get("record", {}) @@ -151,6 +217,18 @@ class CollectEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> CollectEvent: + """Build a CollectEvent from a ``calling.call.collect`` payload. + + Carries the ``result`` object holding what was collected (digits or speech) + and ``final``, which distinguishes an interim result from the last one. Note + ``final`` stays ``None`` when the payload omits it — absent is not False. + + Args: + payload: The raw event payload dict. + + Returns: + A CollectEvent for the collect operation named by ``control_id``. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -174,6 +252,17 @@ class ConnectEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> ConnectEvent: + """Build a ConnectEvent from a ``calling.call.connect`` payload. + + Carries ``connect_state`` (connecting/connected/disconnected/failed) and the + ``peer`` object identifying the far end of the connection. + + Args: + payload: The raw event payload dict. + + Returns: + A ConnectEvent describing the connection attempt. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -195,6 +284,18 @@ class DetectEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> DetectEvent: + """Build a DetectEvent from a ``calling.call.detect`` payload. + + Keeps the whole ``detect`` object, whose shape depends on the detector that + produced it (machine, fax or DTMF), rather than flattening one detector's + fields onto the event. + + Args: + payload: The raw event payload dict. + + Returns: + A DetectEvent for the detect operation named by ``control_id``. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -216,6 +317,17 @@ class FaxEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> FaxEvent: + """Build a FaxEvent from a ``calling.call.fax`` payload. + + Keeps the whole ``fax`` object, which carries the direction-specific result + (pages, identity, document URL) for the send or receive operation. + + Args: + payload: The raw event payload dict. + + Returns: + A FaxEvent for the fax operation named by ``control_id``. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -239,6 +351,17 @@ class TapEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> TapEvent: + """Build a TapEvent from a ``calling.call.tap`` payload. + + Carries the tap's ``state`` plus two objects: ``tap`` describing the media + being tapped and ``device`` describing where it is being sent. + + Args: + payload: The raw event payload dict. + + Returns: + A TapEvent for the tap operation named by ``control_id``. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -264,6 +387,17 @@ class StreamEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> StreamEvent: + """Build a StreamEvent from a ``calling.call.stream`` payload. + + Carries the stream's ``state``, the destination ``url`` and the caller-assigned + ``name`` used to address the stream in later requests. + + Args: + payload: The raw event payload dict. + + Returns: + A StreamEvent for the stream operation named by ``control_id``. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -287,6 +421,17 @@ class SendDigitsEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> SendDigitsEvent: + """Build a SendDigitsEvent from a ``calling.call.send_digits`` payload. + + Carries the ``state`` of the DTMF send, which is how a caller knows the digits + have finished playing out. + + Args: + payload: The raw event payload dict. + + Returns: + A SendDigitsEvent for the operation named by ``control_id``. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -309,6 +454,18 @@ class DialEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> DialEvent: + """Build a DialEvent from a ``calling.call.dial`` payload. + + A dial is correlated by ``tag`` rather than ``control_id``. Carries + ``dial_state`` and, once the dial succeeds, the ``call`` object describing the + call that was created. + + Args: + payload: The raw event payload dict. + + Returns: + A DialEvent for the dial identified by ``tag``. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -333,6 +490,19 @@ class ReferEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> ReferEvent: + """Build a ReferEvent from a ``calling.call.refer`` payload. + + Carries the SIP REFER outcome: ``sip_refer_to`` (the target), plus the two + response codes that report it — ``sip_refer_response_code`` for the REFER + itself and ``sip_notify_response_code`` for the NOTIFY that reports the + transfer result. + + Args: + payload: The raw event payload dict. + + Returns: + A ReferEvent describing the REFER and its responses. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -355,6 +525,17 @@ class DenoiseEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> DenoiseEvent: + """Build a DenoiseEvent from a ``calling.call.denoise`` payload. + + Carries ``denoised``, the boolean reporting whether noise reduction is now + active on the call. + + Args: + payload: The raw event payload dict. + + Returns: + A DenoiseEvent reporting the denoise state. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -375,6 +556,17 @@ class PayEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> PayEvent: + """Build a PayEvent from a ``calling.call.pay`` payload. + + Carries the ``state`` of the payment session so a caller can follow it through + to completion or failure. + + Args: + payload: The raw event payload dict. + + Returns: + A PayEvent for the pay operation named by ``control_id``. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -400,6 +592,18 @@ class QueueEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> QueueEvent: + """Build a QueueEvent from a ``calling.call.queue`` payload. + + Carries the queue's identity (``queue_id``, ``queue_name``) and the call's + place in it (``position`` within a queue of ``size``), alongside the + operation ``status``. + + Args: + payload: The raw event payload dict. + + Returns: + A QueueEvent describing the call's position in the queue. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -424,6 +628,17 @@ class EchoEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> EchoEvent: + """Build an EchoEvent from a ``calling.call.echo`` payload. + + Carries the ``state`` of the echo operation, which loops the call's audio + back to the caller for connectivity testing. + + Args: + payload: The raw event payload dict. + + Returns: + An EchoEvent reporting the echo state. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -448,6 +663,18 @@ class TranscribeEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> TranscribeEvent: + """Build a TranscribeEvent from a ``calling.call.transcribe`` payload. + + Carries the transcription's ``state`` plus the artifact it produced: ``url``, + ``recording_id``, ``duration`` and ``size``. Unlike RecordEvent these are read + only from the top level of ``params`` — transcribe has no nested object. + + Args: + payload: The raw event payload dict. + + Returns: + A TranscribeEvent for the operation named by ``control_id``. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -472,6 +699,16 @@ class HoldEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> HoldEvent: + """Build a HoldEvent from a ``calling.call.hold`` payload. + + Carries the ``state`` reporting whether the call is now held or resumed. + + Args: + payload: The raw event payload dict. + + Returns: + A HoldEvent reporting the hold state. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -493,6 +730,18 @@ class ConferenceEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> ConferenceEvent: + """Build a ConferenceEvent from a ``calling.conference`` payload. + + Carries the conference's identity (``conference_id``, ``name``) and its + ``status``. Note this is a conference-scoped event rather than a call-scoped + one, so the inherited ``call_id`` may be empty. + + Args: + payload: The raw event payload dict. + + Returns: + A ConferenceEvent describing the conference state change. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -515,6 +764,18 @@ class CallingErrorEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> CallingErrorEvent: + """Build a CallingErrorEvent from a ``calling.error`` payload. + + Carries the error ``code`` and human-readable ``message`` reported by the + calling service. This is the event a handler inspects when an operation fails + rather than transitioning to its next state. + + Args: + payload: The raw event payload dict. + + Returns: + A CallingErrorEvent carrying the error code and message. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -544,6 +805,19 @@ class MessageReceiveEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> MessageReceiveEvent: + """Build a MessageReceiveEvent from a ``messaging.receive`` payload. + + This is the inbound-message notification. Carries the message identity + (``message_id``, ``context``, ``tags``), its addressing (``from_number``, + ``to_number``, ``direction``) and its content (``body``, ``media`` URLs and the + ``segments`` count), plus ``message_state``. + + Args: + payload: The raw event payload dict. + + Returns: + A MessageReceiveEvent describing the inbound message. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( @@ -582,6 +856,18 @@ class MessageStateEvent(RelayEvent): @classmethod def from_payload(cls, payload: dict[str, Any]) -> MessageStateEvent: + """Build a MessageStateEvent from a ``messaging.state`` payload. + + Reports a state change on an OUTBOUND message. Carries the same identity, + addressing and content fields as MessageReceiveEvent, plus ``reason`` — which + is what explains a failed or undelivered ``message_state``. + + Args: + payload: The raw event payload dict. + + Returns: + A MessageStateEvent describing the outbound message's new state. + """ base = RelayEvent.from_payload(payload) p = base.params return cls( diff --git a/signalwire/signalwire/relay/protocol_types_generated.py b/signalwire/signalwire/relay/protocol_types_generated.py index 8756b49a..f5fbd2b7 100644 --- a/signalwire/signalwire/relay/protocol_types_generated.py +++ b/signalwire/signalwire/relay/protocol_types_generated.py @@ -176,6 +176,9 @@ class CallingCollectStopParams(TypedDict, total=False): node_id: str +CallingConferenceParams: TypeAlias = "dict[str, Any]" + + class CallingConnectParams(TypedDict, total=False): """Wire schema for the JSON payload of `calling.connect` (params). Extracted from switchblade `PublicCallConnectParams.cs`. @@ -1037,6 +1040,9 @@ class CallingCollectStopResult(TypedDict, total=False): message: str +CallingConferenceResult: TypeAlias = "dict[str, Any]" + + class CallingConnectResult(TypedDict, total=False): """Wire schema for the JSON payload of `calling.connect` (result). Extracted from switchblade `PublicCallConnectResult.cs`. diff --git a/signalwire/signalwire/rest/_base.py b/signalwire/signalwire/rest/_base.py index 41698201..93c346cf 100644 --- a/signalwire/signalwire/rest/_base.py +++ b/signalwire/signalwire/rest/_base.py @@ -288,6 +288,26 @@ def get( params: dict[str, Any] | None = None, request_options: RequestOptions | None = None, ) -> Any: + """Issue a ``GET`` to ``path`` and return the decoded JSON body. + + Args: + path: Absolute API path (e.g. ``/api/fabric/resources``), appended to + the client's ``scheme://host`` base URL. + params: Query-string parameters. ``None`` values are dropped from the + URL recorded on an error; list values expand repeated-key style. + request_options: Per-call transport overrides (timeout / retries / + backoff / abort signal) shallow-merged over the client default. + + Returns: + The parsed JSON body, or ``{}`` for a ``204`` or an empty body. + + Raises: + SignalWireRestError: On a non-2xx response, or on a 2xx whose body is + not decodable JSON. + SignalWireRestTransportError: If no response was ever received + (connection refused, DNS failure, TLS error, timeout) or the + ``abort_signal`` was set before an attempt. + """ return self._request( "GET", path, params=params, request_options=request_options ) @@ -299,6 +319,27 @@ def post( params: dict[str, Any] | None = None, request_options: RequestOptions | None = None, ) -> Any: + """Issue a ``POST`` to ``path`` with ``body`` JSON-encoded, returning the + decoded JSON response. + + Unlike :meth:`get`, this is a non-idempotent method: on a retryable + failure it retries only for a transport error or a ``429``/``503`` + throttle, never blindly on ``500``/``502``/``504``. + + Args: + path: Absolute API path. + body: Value serialised as the JSON request body. ``None`` sends no body. + params: Query-string parameters (some create endpoints take both). + request_options: Per-call transport overrides. + + Returns: + The parsed JSON body, or ``{}`` for a ``204`` or an empty body. + + Raises: + SignalWireRestError: On a non-2xx response, or an undecodable 2xx body. + SignalWireRestTransportError: If no response was received or the request + was cancelled via ``abort_signal``. + """ return self._request( "POST", path, body=body, params=params, request_options=request_options ) @@ -309,6 +350,24 @@ def put( body: Any = None, request_options: RequestOptions | None = None, ) -> Any: + """Issue a ``PUT`` to ``path`` with ``body`` JSON-encoded (full replace). + + Takes no query parameters, unlike :meth:`get`/:meth:`post`. ``PUT`` is + treated as idempotent, so it retries on the full ``retry_on_status`` set. + + Args: + path: Absolute API path, usually a specific item (``/``). + body: Value serialised as the JSON request body. + request_options: Per-call transport overrides. + + Returns: + The parsed JSON body, or ``{}`` for a ``204`` or an empty body. + + Raises: + SignalWireRestError: On a non-2xx response, or an undecodable 2xx body. + SignalWireRestTransportError: If no response was received or the request + was cancelled via ``abort_signal``. + """ return self._request("PUT", path, body=body, request_options=request_options) def patch( @@ -317,9 +376,45 @@ def patch( body: Any = None, request_options: RequestOptions | None = None, ) -> Any: + """Issue a ``PATCH`` to ``path`` with ``body`` JSON-encoded (partial update). + + Takes no query parameters. Like :meth:`post`, ``PATCH`` is non-idempotent, + so it retries only on a transport error or a ``429``/``503`` throttle. + + Args: + path: Absolute API path, usually a specific item (``/``). + body: Value serialised as the JSON request body — only the fields to change. + request_options: Per-call transport overrides. + + Returns: + The parsed JSON body, or ``{}`` for a ``204`` or an empty body. + + Raises: + SignalWireRestError: On a non-2xx response, or an undecodable 2xx body. + SignalWireRestTransportError: If no response was received or the request + was cancelled via ``abort_signal``. + """ return self._request("PATCH", path, body=body, request_options=request_options) def delete(self, path: str, request_options: RequestOptions | None = None) -> Any: + """Issue a ``DELETE`` to ``path``, returning the decoded JSON response. + + Sends neither a body nor query parameters. ``DELETE`` is treated as + idempotent, so it retries on the full ``retry_on_status`` set. SignalWire + delete endpoints typically answer ``204``, which surfaces here as ``{}``. + + Args: + path: Absolute API path of the item to delete. + request_options: Per-call transport overrides. + + Returns: + The parsed JSON body, or ``{}`` for a ``204`` or an empty body. + + Raises: + SignalWireRestError: On a non-2xx response, or an undecodable 2xx body. + SignalWireRestTransportError: If no response was received or the request + was cancelled via ``abort_signal``. + """ return self._request("DELETE", path, request_options=request_options) @@ -345,6 +440,28 @@ class ReadResource(BaseResource, Generic[TList, TItem]): def list( self, *, request_options: RequestOptions | None = None, **params: Any ) -> TList: + """Fetch ONE raw page from this resource's collection endpoint. + + ``GET``s the resource's ``base_path`` verbatim (no id segment appended) + and returns the server's response as-is — the envelope, not the items. + This does NOT follow pagination links: use :meth:`paginate` to iterate + every item across all pages. + + Args: + request_options: Per-call transport overrides (timeout / retries / + backoff / abort signal). + **params: Arbitrary filter/paging query parameters, sent as the query + string. Passing none sends no query string at all. + + Returns: + The decoded list envelope, statically typed as this resource's + ``TList`` binding. At runtime it is the raw JSON dict from the server; + the type parameter is static only. + + Raises: + SignalWireRestError: On a non-2xx response, or an undecodable 2xx body. + SignalWireRestTransportError: If no response was received. + """ return cast( TList, self._http.get( @@ -382,6 +499,27 @@ def paginate( def get( self, resource_id: str, *, request_options: RequestOptions | None = None ) -> TItem: + """Fetch a single item of this resource by id. + + ``GET``s ``/``. Distinct from + :meth:`HttpClient.get`, which takes a caller-built absolute path and no id: + here the path is composed from the resource's own ``base_path``, and no + query parameters are sent. + + Args: + resource_id: Identifier appended as the final path segment. Stringified + as-is, so it must already be URL-safe. + request_options: Per-call transport overrides. + + Returns: + The decoded item, statically typed as this resource's ``TItem`` + binding; a raw JSON dict at runtime. + + Raises: + SignalWireRestError: On a non-2xx response — notably ``404`` for an + unknown id — or an undecodable 2xx body. + SignalWireRestTransportError: If no response was received. + """ return cast( TItem, self._http.get(self._path(resource_id), request_options=request_options), @@ -403,6 +541,28 @@ class CrudResource(ReadResource[TList, TItem], Generic[TList, TItem, TCreate, TU def create( self, *, request_options: RequestOptions | None = None, **kwargs: Any ) -> TItem: + """Create a new item in this collection. + + ``POST``s ``base_path`` with the keyword arguments serialised **as the JSON + request body** — not as a query string, which is how they differ from + :meth:`list`'s ``**params``. + + Concrete generated resources override this with a closed, spec-typed + signature; this base accepts arbitrary wire fields. + + Args: + request_options: Per-call transport overrides. + **kwargs: Fields of the create request, sent verbatim as the JSON body. + + Returns: + The created item as returned by the server, statically typed as + ``TItem``; a raw JSON dict at runtime. + + Raises: + SignalWireRestError: On a non-2xx response — notably ``422`` for an + invalid body — or an undecodable 2xx body. + SignalWireRestTransportError: If no response was received. + """ # Honest fallback: the body accepts arbitrary wire fields and at runtime # is a plain dict. Concrete resources override this with a generated # CLOSED typed signature (explicit spec fields + an ``extras`` door); the @@ -425,6 +585,31 @@ def update( request_options: RequestOptions | None = None, **kwargs: Any, ) -> TItem: + """Update an existing item by id, sending only the given fields. + + Dispatches on the class attribute ``_update_method``: ``PATCH`` by default + (partial update), or ``PUT`` for resources that bind + :class:`FabricResourcePUT`. The keyword arguments become the JSON request + body, sent to ``/``. + + Concrete generated resources override this with a closed, spec-typed + signature; this base accepts arbitrary wire fields. + + Args: + resource_id: Identifier of the item to update, appended as the final + path segment. Positional-only, so a subclass may rename it. + request_options: Per-call transport overrides. + **kwargs: Fields to change, sent verbatim as the JSON body. + + Returns: + The updated item as returned by the server, statically typed as + ``TItem``; a raw JSON dict at runtime. + + Raises: + SignalWireRestError: On a non-2xx response — notably ``404`` for an + unknown id or ``422`` for an invalid body — or an undecodable 2xx body. + SignalWireRestTransportError: If no response was received. + """ # resource_id is positional-only so a subclass may rename it without an LSP # override conflict. Same contract as ``create``: honest ``**kwargs: Any`` # fallback; the concrete generated override carries the closed typed shape, the @@ -442,6 +627,27 @@ def update( def delete( self, resource_id: str, *, request_options: RequestOptions | None = None ) -> TItem: + """Delete a single item of this resource by id. + + ``DELETE``s ``/``. Distinct from + :meth:`HttpClient.delete`, which takes a caller-built absolute path: here + the path is composed from the resource's own ``base_path``. + + Args: + resource_id: Identifier of the item to delete, appended as the final + path segment. + request_options: Per-call transport overrides. + + Returns: + Statically typed as ``TItem``, but SignalWire delete endpoints + typically answer ``204`` with no body, which arrives here as ``{}``. + Do not rely on the deleted item being echoed back. + + Raises: + SignalWireRestError: On a non-2xx response — notably ``404`` for an + unknown id. + SignalWireRestTransportError: If no response was received. + """ return cast( TItem, self._http.delete(self._path(resource_id), request_options=request_options), @@ -458,6 +664,28 @@ def list_addresses( request_options: RequestOptions | None = None, **params: Any, ) -> Any: + """List the addresses belonging to one item of this resource. + + ``GET``s the sibling sub-collection ``//addresses``. + Unlike :meth:`list`, which pages the resource's OWN collection, this lists a + different collection nested under a single item, so it requires an id. + + Args: + resource_id: Identifier of the owning item. + request_options: Per-call transport overrides. + **params: Filter/paging query parameters for the addresses collection. + Passing none sends no query string. + + Returns: + The decoded addresses list envelope. Untyped (``Any``) — this method + carries no ``TList``-style type parameter, so it is the raw server JSON + both statically and at runtime. + + Raises: + SignalWireRestError: On a non-2xx response — notably ``404`` for an + unknown ``resource_id``. + SignalWireRestTransportError: If no response was received. + """ return self._http.get( self._path(resource_id, "addresses"), params=params or None, diff --git a/signalwire/signalwire/rest/_request_options.py b/signalwire/signalwire/rest/_request_options.py index 541c50bc..7296c600 100644 --- a/signalwire/signalwire/rest/_request_options.py +++ b/signalwire/signalwire/rest/_request_options.py @@ -33,7 +33,16 @@ class _AbortSignal(Protocol): if set, the request raises rather than proceeding. """ - def is_set(self) -> bool: ... + def is_set(self) -> bool: + """Whether cancellation has been requested. + + Polled by the request loop before each attempt; returning ``True`` makes + the pending request raise :class:`~signalwire.rest._base.SignalWireRestTransportError` + instead of being sent. Cancellation is cooperative and checked only + *between* attempts, so an already in-flight blocking read is not + interrupted — a request already on the wire completes normally. + """ + ... # The built-in defaults (the contract floor). ``None`` on a RequestOptions field diff --git a/signalwire/signalwire/search/__init__.py b/signalwire/signalwire/search/__init__.py index d6189a6a..55834460 100644 --- a/signalwire/signalwire/search/__init__.py +++ b/signalwire/signalwire/search/__init__.py @@ -115,25 +115,94 @@ def _check_search_dependencies() -> None: # These conditional fallbacks intentionally shadow the real imports above # when optional deps are absent; mypy can't model that mutual exclusion. def preprocess_query(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] + """Unavailable-dependency stub for :func:`.query_processor.preprocess_query`. + + Bound under this name only when one of numpy, scikit-learn, + sentence-transformers or nltk is missing, so ``from signalwire.search + import preprocess_query`` still succeeds without the extras installed. + Accepts and ignores any arguments; it never preprocesses a query. + + Raises: + ImportError: Always, naming the missing packages and the + ``pip install signalwire-sdk[search]`` command that supplies them. + """ _check_search_dependencies() def preprocess_document_content(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] + """Unavailable-dependency stub for + :func:`.query_processor.preprocess_document_content`. + + Bound under this name only when the search extras are missing, so the + import resolves and the failure is deferred to the call site with an + actionable message rather than raised at import time. + + Raises: + ImportError: Always, listing the missing search dependencies. + """ _check_search_dependencies() class DocumentProcessor: # type: ignore[no-redef] + """Unavailable-dependency stub for + :class:`.document_processor.DocumentProcessor`. + + Substituted for the real chunker when the search extras are absent so + that importing the name works; every attempt to construct one fails. + """ + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Reject construction because the search extras are not installed. + + Raises: + ImportError: Always, listing the missing search dependencies. + """ _check_search_dependencies() class IndexBuilder: # type: ignore[no-redef] + """Unavailable-dependency stub for :class:`.index_builder.IndexBuilder`. + + Substituted for the real index builder when the search extras are + absent; construction always fails rather than silently building nothing. + """ + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Reject construction because the search extras are not installed. + + Raises: + ImportError: Always, listing the missing search dependencies. + """ _check_search_dependencies() class SearchEngine: # type: ignore[no-redef] + """Unavailable-dependency stub for :class:`.search_engine.SearchEngine`. + + Substituted for the real query engine when the search extras are absent. + Note this stub is bound even for query-only workloads: embedding a query + needs sentence-transformers, so no search can run without the extras. + """ + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Reject construction because the search extras are not installed. + + Raises: + ImportError: Always, listing the missing search dependencies. + """ _check_search_dependencies() class SearchService: # type: ignore[no-redef] + """Unavailable-dependency stub for + :class:`.search_service.SearchService`. + + Substituted for the real HTTP search service when the search extras are + absent. Unlike the real class — which degrades to direct search when + only FastAPI is missing — this stub cannot serve or search at all. + """ + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Reject construction because the search extras are not installed. + + Raises: + ImportError: Always, listing the missing search dependencies. + """ _check_search_dependencies() __all__ = [ diff --git a/signalwire/signalwire/search/document_processor.py b/signalwire/signalwire/search/document_processor.py index 40969219..5a99e071 100644 --- a/signalwire/signalwire/search/document_processor.py +++ b/signalwire/signalwire/search/document_processor.py @@ -507,6 +507,15 @@ def _chunk_markdown_ast(self, content: str, filename: str) -> list[dict[str, Any current_has_code = False def flush() -> None: + """Emit the accumulated lines as one chunk and reset the accumulator. + + Appends to the enclosing ``chunks`` list, tagging the chunk with the + current heading hierarchy (as both a section path and metadata), its + source line range, and any fenced-code languages seen. Whitespace-only + accumulations are dropped rather than emitted, but the accumulator is + cleared either way, so calling this at a heading boundary or a + size-driven split is always safe. + """ nonlocal current_lines, current_start_line, current_end_line nonlocal \ current_size, \ diff --git a/signalwire/signalwire/search/index_builder.py b/signalwire/signalwire/search/index_builder.py index 71ab1b58..4bcf1ea2 100644 --- a/signalwire/signalwire/search/index_builder.py +++ b/signalwire/signalwire/search/index_builder.py @@ -10,6 +10,7 @@ import sqlite3 import json import hashlib +from contextlib import closing from datetime import datetime from pathlib import Path from typing import Any, TYPE_CHECKING @@ -779,39 +780,44 @@ def validate_index(self, index_file: str) -> dict[str, Any]: return {"valid": False, "error": "Index file does not exist"} try: - conn = sqlite3.connect(index_file) - cursor = conn.cursor() - - # Check schema - cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") - tables = [row[0] for row in cursor.fetchall()] - - required_tables = ["chunks", "chunks_fts", "synonyms", "config"] - missing_tables = [t for t in required_tables if t not in tables] - - if missing_tables: - return {"valid": False, "error": f"Missing tables: {missing_tables}"} - - # Get config - cursor.execute("SELECT key, value FROM config") - config = dict(cursor.fetchall()) - - # Get chunk count - cursor.execute("SELECT COUNT(*) FROM chunks") - chunk_count = cursor.fetchone()[0] - - # Get file count - cursor.execute("SELECT COUNT(DISTINCT filename) FROM chunks") - file_count = cursor.fetchone()[0] - - conn.close() - - return { - "valid": True, - "chunk_count": chunk_count, - "file_count": file_count, - "config": config, - } + # `closing` (not `with sqlite3.connect(...)`) — a Connection used as a + # context manager commits/rolls back the transaction but does NOT close + # the handle. On Windows an unclosed handle makes the file undeletable + # (PermissionError/WinError 32), so every exit path must close. + with closing(sqlite3.connect(index_file)) as conn: + cursor = conn.cursor() + + # Check schema + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = [row[0] for row in cursor.fetchall()] + + required_tables = ["chunks", "chunks_fts", "synonyms", "config"] + missing_tables = [t for t in required_tables if t not in tables] + + if missing_tables: + return { + "valid": False, + "error": f"Missing tables: {missing_tables}", + } + + # Get config + cursor.execute("SELECT key, value FROM config") + config = dict(cursor.fetchall()) + + # Get chunk count + cursor.execute("SELECT COUNT(*) FROM chunks") + chunk_count = cursor.fetchone()[0] + + # Get file count + cursor.execute("SELECT COUNT(DISTINCT filename) FROM chunks") + file_count = cursor.fetchone()[0] + + return { + "valid": True, + "chunk_count": chunk_count, + "file_count": file_count, + "config": config, + } except Exception as e: return {"valid": False, "error": str(e)} diff --git a/signalwire/signalwire/search/migration.py b/signalwire/signalwire/search/migration.py index e12e36d6..091844e8 100644 --- a/signalwire/signalwire/search/migration.py +++ b/signalwire/signalwire/search/migration.py @@ -9,6 +9,7 @@ import sqlite3 import json +from contextlib import closing from typing import Any, TYPE_CHECKING from signalwire.core.logging_config import get_logger @@ -449,21 +450,21 @@ def get_index_info(self, index_path: str) -> dict[str, Any]: info["type"] = "sqlite" info["path"] = index_path - conn = sqlite3.connect(index_path) - cursor = conn.cursor() - - # Get config - cursor.execute("SELECT key, value FROM config") - info["config"] = dict(cursor.fetchall()) + # `closing` so the handle is released even when a query raises — + # an unclosed handle makes the file undeletable on Windows. + with closing(sqlite3.connect(index_path)) as conn: + cursor = conn.cursor() - # Get stats - cursor.execute("SELECT COUNT(*) FROM chunks") - info["total_chunks"] = cursor.fetchone()[0] + # Get config + cursor.execute("SELECT key, value FROM config") + info["config"] = dict(cursor.fetchall()) - cursor.execute("SELECT COUNT(DISTINCT filename) FROM chunks") - info["total_files"] = cursor.fetchone()[0] + # Get stats + cursor.execute("SELECT COUNT(*) FROM chunks") + info["total_chunks"] = cursor.fetchone()[0] - conn.close() + cursor.execute("SELECT COUNT(DISTINCT filename) FROM chunks") + info["total_files"] = cursor.fetchone()[0] else: info["type"] = "unknown" diff --git a/signalwire/signalwire/search/query_processor.py b/signalwire/signalwire/search/query_processor.py index 8f69d988..ab08bb69 100644 --- a/signalwire/signalwire/search/query_processor.py +++ b/signalwire/signalwire/search/query_processor.py @@ -7,12 +7,19 @@ See LICENSE file in the project root for full license information. """ -import nltk import re import threading from typing import Any -from nltk.corpus import wordnet as wn -from nltk.stem import PorterStemmer + +try: + import nltk + from nltk.corpus import wordnet as wn + from nltk.stem import PorterStemmer +except ImportError: # pragma: no cover - exercised with the extra simulated absent + raise ImportError( + "nltk is required for search query processing. " + "Install it with: pip install signalwire-sdk[search]" + ) from None from signalwire.core.logging_config import get_logger diff --git a/signalwire/signalwire/search/search_service.py b/signalwire/signalwire/search/search_service.py index d7193718..52cdcd7b 100644 --- a/signalwire/signalwire/search/search_service.py +++ b/signalwire/signalwire/search/search_service.py @@ -49,6 +49,22 @@ if BaseModel is not None: class SearchRequest(BaseModel): + """Body of a ``POST /search`` request (pydantic-validated variant). + + Attributes: + query: Natural-language search text. Expanded by + ``preprocess_query`` before matching. + index_name: Key into the service's configured indexes — an + ``.swsearch`` file for the sqlite backend, a collection name for + pgvector. An unknown key yields HTTP 404. + count: Maximum number of results to return. + similarity_threshold: Minimum similarity a chunk must reach to be + returned; 0.0 applies no floor. + tags: Restrict results to chunks carrying all of these tags. + language: Language code for query preprocessing; ``None`` is sent + to the preprocessor as ``"auto"`` for detection. + """ + query: str index_name: str = "default" count: int = 3 @@ -57,17 +73,45 @@ class SearchRequest(BaseModel): language: str | None = None class SearchResult(BaseModel): + """One matched chunk in a search response (pydantic-validated variant). + + Attributes: + content: The chunk text as stored in the index. + score: Similarity of this chunk to the query; higher is closer. + metadata: Index-supplied chunk metadata (source filename, section + path, line range, detected code languages, and so on). + """ + content: str score: float metadata: dict[str, Any] class SearchResponse(BaseModel): + """Body of a ``POST /search`` response (pydantic-validated variant). + + Attributes: + results: Matched chunks, best first. Empty when the index yielded + nothing or the underlying search raised — the service logs the + error and returns an empty list rather than failing the request. + query_analysis: What the preprocessor made of the query — + ``original_query``, ``enhanced_query``, ``detected_language`` + and ``pos_analysis``. + """ + results: list[SearchResult] query_analysis: dict[str, Any] | None = None else: # Fallback classes when FastAPI is not available; these intentionally # shadow the pydantic versions above when the optional dep is absent. class SearchRequest: # type: ignore[no-redef] + """Plain-object search request used when pydantic is not installed. + + Field-for-field equivalent to the pydantic ``SearchRequest`` above, but + with no validation or coercion: whatever you pass is stored as-is. Only + :meth:`SearchService.search_direct` constructs it in this mode — without + FastAPI there is no HTTP route to receive one. + """ + def __init__( self, query: str, @@ -77,6 +121,16 @@ def __init__( tags: list[str] | None = None, language: str | None = None, ): + """Store the search parameters verbatim. + + Args: + query: Natural-language search text. + index_name: Key into the service's configured indexes. + count: Maximum number of results to return. + similarity_threshold: Minimum similarity a chunk must reach. + tags: Restrict results to chunks carrying all of these tags. + language: Language code for preprocessing; ``None`` means auto. + """ self.query = query self.index_name = index_name self.count = count @@ -85,17 +139,47 @@ def __init__( self.language = language class SearchResult: # type: ignore[no-redef] + """Plain-object search result used when pydantic is not installed. + + Carries the same three fields as the pydantic ``SearchResult`` above + with no validation. ``_handle_search`` builds these from the raw dicts + the search engine returns, and ``search_direct`` flattens them back to + dicts for the caller. + """ + def __init__(self, content: str, score: float, metadata: dict[str, Any]): + """Store one matched chunk. + + Args: + content: The chunk text as stored in the index. + score: Similarity of this chunk to the query; higher is closer. + metadata: Index-supplied chunk metadata. + """ self.content = content self.score = score self.metadata = metadata class SearchResponse: # type: ignore[no-redef] + """Plain-object search response used when pydantic is not installed. + + Equivalent to the pydantic ``SearchResponse`` above without validation. + Instances are also what the service's in-memory query cache stores, so + a cache hit returns the very same object to every caller. + """ + def __init__( self, results: list[SearchResult], query_analysis: dict[str, Any] | None = None, ): + """Store the matched chunks and the query analysis. + + Args: + results: Matched chunks, best first; empty when nothing matched + or the underlying search raised and was logged. + query_analysis: Preprocessor output — original and enhanced + query text, detected language, and POS analysis. + """ self.results = results self.query_analysis = query_analysis @@ -211,6 +295,17 @@ def _setup_security(self) -> None: async def add_security_headers( request: Request, call_next: Callable[[Request], Awaitable[Response]] ) -> Response: + """Stamp the configured security headers onto every response. + + Args: + request: The incoming request; its URL scheme selects the + header set, so HSTS-style headers are only added over https. + call_next: The rest of the middleware/route chain. + + Returns: + The downstream response, mutated in place with the headers from + ``SecurityConfig.get_security_headers``. + """ response = await call_next(request) # Add security headers @@ -226,6 +321,21 @@ async def add_security_headers( async def validate_host( request: Request, call_next: Callable[[Request], Awaitable[Response]] ) -> Response: + """Reject requests whose Host header is not in the allowed list. + + Guards against DNS-rebinding and Host-header injection. The port is + stripped before the check, and a request with no Host header is let + through. + + Args: + request: The incoming request, read for its ``host`` header. + call_next: The rest of the middleware/route chain. + + Returns: + A bare ``400 Invalid host`` response when + ``SecurityConfig.should_allow_host`` rejects the host; + otherwise the downstream response unchanged. + """ host = request.headers.get("host", "").split(":")[0] if host and not self.security.should_allow_host(host): return Response(content="Invalid host", status_code=400) @@ -270,6 +380,12 @@ def _setup_routes(self) -> None: # Create dependency for authenticated routes def get_authenticated() -> Any: + """Return the HTTP Basic security scheme, or ``None`` if disabled. + + Returns: + The module's ``HTTPBasic`` instance when fastapi.security was + importable, else ``None`` — meaning routes run unauthenticated. + """ if security: return security return None @@ -281,12 +397,42 @@ async def search( if not security else Depends(security), # noqa: B008 # FastAPI DI: Depends() in default is the intended idiom ) -> SearchResponse: + """Handle ``POST /search``. + + Args: + request: The parsed :class:`SearchRequest` body. + credentials: HTTP Basic credentials, injected by FastAPI when + the security scheme is active; ``None`` when it is not. + + Returns: + A :class:`SearchResponse`, served from the service's query + cache when the same query/index/count/tags combination was seen + before. + + Raises: + HTTPException: 401 if the credentials do not match the + service's basic-auth pair, or 404 if ``index_name`` is not + one of the loaded indexes. + """ if security: self._get_current_username(credentials) return await self._handle_search(request) @self.app.get("/health") async def health() -> dict[str, Any]: + """Handle ``GET /health``. + + Unauthenticated — it is registered without the security dependency + so a load balancer can probe it. Reports liveness only; it does not + re-check that the indexes still load. + + Returns: + A dict with a constant ``status`` of ``"healthy"``, the active + ``backend``, the configured index names, whether SSL and auth + are enabled, and ``connection_string`` — masked to ``"***"`` on + the pgvector backend and ``None`` otherwise, so the DSN never + leaks. + """ return { "status": "healthy", "backend": self.backend, @@ -436,12 +582,14 @@ def _get_model_name(self, index_path: str) -> str: # SQLite backend try: import sqlite3 - - conn = sqlite3.connect(index_path) - cursor = conn.cursor() - cursor.execute("SELECT value FROM config WHERE key = 'embedding_model'") - result = cursor.fetchone() - conn.close() + from contextlib import closing + + # `closing` so the handle is released even when the query raises — + # an unclosed handle makes the file undeletable on Windows. + with closing(sqlite3.connect(index_path)) as conn: + cursor = conn.cursor() + cursor.execute("SELECT value FROM config WHERE key = 'embedding_model'") + result = cursor.fetchone() return result[0] if result else "sentence-transformers/all-mpnet-base-v2" except Exception as e: logger.warning(f"Could not get model name from index: {e}") diff --git a/signalwire/signalwire/skills/README.md b/signalwire/signalwire/skills/README.md index 920aed85..0b614838 100644 --- a/signalwire/signalwire/skills/README.md +++ b/signalwire/signalwire/skills/README.md @@ -175,9 +175,13 @@ class YourSkillClass(SkillBase): - Return data available to DataMap expressions - Access via `${global.key}` in DataMap configurations -#### `get_prompt_sections(self) -> List[Dict[str, Any]]` +#### `_get_prompt_sections(self) -> List[Dict[str, Any]]` - Return prompt sections to add to the agent - Structure: `{"title": str, "body": str, "bullets": List[str]}` +- **Override this protected hook, not the public `get_prompt_sections()`.** + The public method is the guard-bearing entry point: it returns `[]` when the + skill is configured with `skip_prompt: True` and otherwise delegates here. + Overriding the public method disables `skip_prompt` for that skill. ## DataMap Integration diff --git a/signalwire/signalwire/skills/claude_skills/skill.py b/signalwire/signalwire/skills/claude_skills/skill.py index 6a6f8a58..02b78a09 100644 --- a/signalwire/signalwire/skills/claude_skills/skill.py +++ b/signalwire/signalwire/skills/claude_skills/skill.py @@ -443,6 +443,21 @@ def _execute_shell_injection( """ def replace_command(match: re.Match[str]) -> str: + """ + Run one matched ``!`command`` snippet and return its stdout. + + Args: + match: A ``_SHELL_INJECTION_RE`` match whose group 1 is the command + text between the backticks. + + Returns: + The command's stdout with trailing newlines stripped, or a bracketed + placeholder (``[command timed out: ...]`` / ``[command error: ...]``) + when the command exceeds ``timeout`` or raises. Failures are logged + and never propagate, so a broken command degrades the rendered skill + body instead of failing the tool call. The command runs through the + shell with the skill directory as its working directory. + """ command = match.group(1) try: result = subprocess.run( # noqa: S602 # intentional feature: runs shell snippets authored in skill body files (developer-controlled, like Claude Skills), gated behind opt-in allow_shell_injection (default False) which logs a warning; shell=True is required to support pipes/redirection in authored commands; not reachable from end-user runtime input @@ -505,6 +520,18 @@ def _substitute_arguments(self, body: str, arguments: str) -> str: # Replace $ARGUMENTS[N] with positional args def replace_indexed(match: re.Match[str]) -> str: + """ + Expand one ``$ARGUMENTS[N]`` placeholder to positional argument N. + + Args: + match: A match of ``\\$ARGUMENTS\\[(\\d+)\\]`` whose group 1 is the + zero-based index. + + Returns: + The whitespace-split argument at that index, or an empty string when + the index is past the end of the argument list (out-of-range + placeholders are erased rather than left literal or raising). + """ index = int(match.group(1)) if index < len(positional): return positional[index] @@ -514,6 +541,22 @@ def replace_indexed(match: re.Match[str]) -> str: # Replace $N shorthand (must do after $ARGUMENTS to avoid conflicts) def replace_shorthand(match: re.Match[str]) -> str: + """ + Expand one ``$N`` shorthand placeholder to positional argument N. + + Same index-to-argument lookup as :func:`replace_indexed`, but driven by + the bare ``\\$(\\d+)(?!\\d)`` pattern instead of the bracketed + ``$ARGUMENTS[N]`` form. It is applied after the bracketed pass so that + the ``N]`` tail of an already-expanded ``$ARGUMENTS[N]`` cannot be + mistaken for a shorthand. + + Args: + match: A match whose group 1 is the zero-based index. + + Returns: + The whitespace-split argument at that index, or an empty string when + the index is past the end of the argument list. + """ index = int(match.group(1)) if index < len(positional): return positional[index] @@ -577,9 +620,47 @@ def register_tools(self) -> None: def make_handler( s: dict[str, Any], rprefix: str, rpostfix: str ) -> Callable[[dict[str, Any], dict[str, Any]], FunctionResult]: + """ + Build the SWAIG handler for one discovered Claude skill. + + A factory is used so each registered tool closes over its OWN skill + dict and response wrappers; without it every handler in the loop + would see the last iteration's values. + + Args: + s: The parsed skill record (``body``, ``sections``, ``skill_dir``). + rprefix: Text prepended to the rendered content, or "". + rpostfix: Text appended to the rendered content, or "". + + Returns: + The ``(args, raw_data) -> FunctionResult`` handler to pass to + ``define_tool``. + """ + def handler( args: dict[str, Any], raw_data: dict[str, Any] ) -> FunctionResult: + """ + Render this skill's content and return it to the agent. + + Picks the source text — the supporting file named by + ``args["section"]`` when it is one of the skill's known sections, + otherwise the SKILL.md body (an unreadable section file logs the + error and yields an ``Error loading section ''`` string). + That text then goes through shell injection (only when the skill + was configured with ``allow_shell_injection``), ``${...}`` + variable substitution, and ``$ARGUMENTS``/``$N`` substitution + using ``args["arguments"]``, before being wrapped in the + configured response prefix/postfix. + + Args: + args: SWAIG arguments; ``section`` and ``arguments`` are read. + raw_data: The raw SWAIG POST body; supplies ``call_id`` for + the ``${CLAUDE_SESSION_ID}`` substitution. + + Returns: + FunctionResult carrying the fully rendered skill content. + """ section = args.get("section") arguments = args.get("arguments", "") diff --git a/signalwire/signalwire/skills/datasphere/skill.py b/signalwire/signalwire/skills/datasphere/skill.py index 200215f8..0a504815 100644 --- a/signalwire/signalwire/skills/datasphere/skill.py +++ b/signalwire/signalwire/skills/datasphere/skill.py @@ -322,7 +322,7 @@ def get_global_data(self) -> dict[str, Any]: "knowledge_provider": "SignalWire DataSphere", } - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """Return prompt sections to add to agent""" return [ { diff --git a/signalwire/signalwire/skills/datasphere_serverless/skill.py b/signalwire/signalwire/skills/datasphere_serverless/skill.py index ab404ebd..1f2f01b2 100644 --- a/signalwire/signalwire/skills/datasphere_serverless/skill.py +++ b/signalwire/signalwire/skills/datasphere_serverless/skill.py @@ -251,7 +251,7 @@ def get_global_data(self) -> dict[str, Any]: "knowledge_provider": "SignalWire DataSphere (Serverless)", } - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """Return prompt sections to add to agent""" return [ { diff --git a/signalwire/signalwire/skills/datetime/skill.py b/signalwire/signalwire/skills/datetime/skill.py index e3ff19a1..481a561b 100644 --- a/signalwire/signalwire/skills/datetime/skill.py +++ b/signalwire/signalwire/skills/datetime/skill.py @@ -101,7 +101,7 @@ def get_hints(self) -> list[str]: # return ["time", "date", "today", "now", "current", "timezone"] return [] - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """Return prompt sections to add to agent""" return [ { diff --git a/signalwire/signalwire/skills/google_maps/skill.py b/signalwire/signalwire/skills/google_maps/skill.py index 8f83c8ed..0c0639a0 100644 --- a/signalwire/signalwire/skills/google_maps/skill.py +++ b/signalwire/signalwire/skills/google_maps/skill.py @@ -135,6 +135,23 @@ def _debug_json(label: str, data: Any) -> None: class GoogleMapsClient: + """ + Thin synchronous wrapper over the Google Places and Routes HTTP APIs. + + Holds the API key and exposes the two operations the skill's SWAIG tools need: + + - :meth:`validate_address` — turn a spoken address or business name into + ``{"address", "lat", "lng"}`` via Nearby Search and/or Autocomplete plus a + Place Details lookup. + - :meth:`compute_route` — turn two coordinate pairs into + ``{"distance_meters", "duration_seconds"}`` via the Routes API. + + Both methods swallow request errors: they log and return None rather than + raising, and they short-circuit to None when the key is empty, so a + misconfigured key surfaces as a "no result" to the agent instead of a failed + tool call. + """ + def __init__(self, api_key: str): self.api_key = api_key @@ -625,7 +642,7 @@ def get_hints(self) -> list[str]: """Return speech recognition hints""" return ["address", "location", "route", "directions", "miles", "distance"] - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """Return prompt sections to add to agent""" return [ { diff --git a/signalwire/signalwire/skills/info_gatherer/skill.py b/signalwire/signalwire/skills/info_gatherer/skill.py index 7d3be7d0..6bf7e44c 100644 --- a/signalwire/signalwire/skills/info_gatherer/skill.py +++ b/signalwire/signalwire/skills/info_gatherer/skill.py @@ -32,6 +32,23 @@ class InfoGathererSkill(SkillBase): @classmethod def get_parameter_schema(cls) -> dict[str, dict[str, Any]]: + """ + Return the base skill parameter schema extended with this skill's config. + + Adds three parameters on top of :meth:`SkillBase.get_parameter_schema`: + + - ``questions`` (array, required) — the question objects, each with + ``key_name`` and ``question_text`` plus optional ``confirm`` and + ``prompt_add``. + - ``prefix`` (string, optional) — namespaces the tool names and the + global_data key so several instances can coexist. + - ``completion_message`` (string, optional) — returned once every question + has been answered. + + Returns: + Dict[str, Dict[str, Any]]: The merged parameter schema, used by the + skill registry to describe this skill's configuration. + """ schema = super().get_parameter_schema() schema.update( { @@ -81,6 +98,20 @@ def get_parameter_schema(cls) -> dict[str, dict[str, Any]]: # ------------------------------------------------------------------ # def get_instance_key(self) -> str: + """ + Return the key that distinguishes this instance from other copies of the skill. + + Overrides the base implementation (which keys on ``tool_name``) to key on the + ``prefix`` param instead, because ``prefix`` is what actually differentiates + two info_gatherer instances — it drives both the tool names + (``_start_questions`` / ``_submit_answer``) and the + global_data namespace. Two instances configured with different prefixes + therefore get different keys and can be loaded onto one agent side by side. + + Returns: + str: ``"info_gatherer_"`` when a prefix is configured, otherwise + ``"info_gatherer"`` — so at most one un-prefixed instance is possible. + """ prefix = self.params.get("prefix") if prefix: return f"info_gatherer_{prefix}" @@ -91,6 +122,22 @@ def get_instance_key(self) -> str: # ------------------------------------------------------------------ # def setup(self) -> bool: + """ + Validate the configuration and precompute this instance's names and messages. + + Requires the ``questions`` param and checks it through + ``_validate_questions``: it must be a non-empty list of dicts, each carrying + both ``key_name`` and ``question_text``. On success it stores the question + list on ``self.questions``, derives ``self.start_tool_name`` and + ``self.submit_tool_name`` (prefixed with the ``prefix`` param when set), and + resolves ``self.completion_message``. + + Returns: + bool: True when the skill is usable. False when ``questions`` is missing + or fails validation — the reason is logged as an error and the skill is + NOT loaded onto the agent, so neither of its tools nor its prompt section + is registered. + """ questions = self.params.get("questions") if questions is None: self.logger.error("'questions' parameter is required") @@ -127,6 +174,20 @@ def setup(self) -> bool: # ------------------------------------------------------------------ # def get_global_data(self) -> dict[str, Any]: + """ + Return this instance's initial questionnaire state for the agent's global_data. + + The state is stored under this skill instance's namespace key + (``skill:``, or ``skill:`` when no prefix is set), which + is what keeps two info_gatherer instances from overwriting each other's + progress. + + Returns: + Dict[str, Any]: ``{namespace: {"questions": [...], "question_index": 0, + "answers": []}}`` — the configured questions plus a cursor at the first + question and an empty answer list. The tool handlers advance + ``question_index`` and append to ``answers`` from here. + """ namespace = self._get_skill_namespace() return { namespace: { @@ -162,6 +223,18 @@ def _get_prompt_sections(self) -> list[dict[str, Any]]: # ------------------------------------------------------------------ # def register_tools(self) -> None: + """ + Register the two SWAIG tools that drive the question loop. + + Both names come from ``setup()`` and carry the configured prefix: + + - ``start_questions`` — no parameters; returns the instruction for the first + unanswered question. + - ``submit_answer`` — takes ``answer`` and ``confirmed_by_user``; records the + answer and returns the next question, or the completion message plus a + ``toggle_functions`` that deactivates both tools once the list is + exhausted. + """ self.define_tool( name=self.start_tool_name, description="Start the question sequence with the first question", diff --git a/signalwire/signalwire/skills/joke/skill.py b/signalwire/signalwire/skills/joke/skill.py index f33ad713..23ab7569 100644 --- a/signalwire/signalwire/skills/joke/skill.py +++ b/signalwire/signalwire/skills/joke/skill.py @@ -111,7 +111,7 @@ def get_global_data(self) -> dict[str, Any]: """Return global data to be available in DataMap variables""" return {"joke_skill_enabled": True} - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """Return prompt sections to add to agent""" return [ { diff --git a/signalwire/signalwire/skills/math/skill.py b/signalwire/signalwire/skills/math/skill.py index 22c8b088..22a9bd03 100644 --- a/signalwire/signalwire/skills/math/skill.py +++ b/signalwire/signalwire/skills/math/skill.py @@ -122,7 +122,7 @@ def get_hints(self) -> list[str]: # ] return [] - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """Return prompt sections to add to agent""" return [ { diff --git a/signalwire/signalwire/skills/mcp_gateway/skill.py b/signalwire/signalwire/skills/mcp_gateway/skill.py index 4ccb9b5b..6700d825 100644 --- a/signalwire/signalwire/skills/mcp_gateway/skill.py +++ b/signalwire/signalwire/skills/mcp_gateway/skill.py @@ -272,6 +272,24 @@ def _register_mcp_tool(self, service_name: str, tool_def: dict[str, Any]) -> Non # Create handler function def handler(args: dict[str, Any], raw_data: dict[str, Any]) -> FunctionResult: + """ + Forward this SWAIG call to its MCP tool through the gateway. + + Defined inside ``_register_mcp_tool`` so it closes over the specific + ``service_name`` and ``tool_name`` for this registration; the loop over + a service's tools therefore produces one distinct handler per tool. + + Args: + args: The SWAIG arguments, passed through unchanged as the MCP + tool's arguments. + raw_data: The raw SWAIG POST body; ``_call_mcp_tool`` reads the + gateway session id from ``global_data.mcp_call_id``, falling + back to ``call_id``. + + Returns: + FunctionResult: whatever ``_call_mcp_tool`` returns for the gateway + response. + """ return self._call_mcp_tool(service_name, tool_name, args, raw_data) # Register the SWAIG function. Forward the MCP tool's required-argument @@ -430,7 +448,7 @@ def get_global_data(self) -> dict[str, Any]: ], } - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """Return prompt sections to add to agent""" sections = [] diff --git a/signalwire/signalwire/skills/native_vector_search/skill.py b/signalwire/signalwire/skills/native_vector_search/skill.py index 452ba344..4d156a60 100644 --- a/signalwire/signalwire/skills/native_vector_search/skill.py +++ b/signalwire/signalwire/skills/native_vector_search/skill.py @@ -10,10 +10,7 @@ import contextlib import os import shutil -from typing import Any, ClassVar, TYPE_CHECKING - -if TYPE_CHECKING: - from signalwire.core.agent_base import AgentBase +from typing import Any, ClassVar from pathlib import Path from signalwire.core.skill_base import SkillBase @@ -889,27 +886,20 @@ def get_global_data(self) -> dict[str, Any]: return global_data - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """Return prompt sections to add to agent""" - # We'll handle this in register_tools after the agent is set - return [] - - def _add_prompt_section(self, agent: "AgentBase") -> None: - """Add prompt section to agent (called during skill loading)""" - try: - agent.prompt_add_section( - title="Local Document Search", - body=f"You can search local document indexes using the {self.tool_name} tool.", - bullets=[ + return [ + { + "title": "Local Document Search", + "body": f"You can search local document indexes using the {self.tool_name} tool.", + "bullets": [ f"Use the {self.tool_name} tool when users ask questions about topics that might be in the indexed documents", "Search for relevant information using clear, specific queries", "Provide helpful summaries of the search results", "If no results are found, suggest the user try rephrasing their question or ask about different topics", ], - ) - except Exception as e: - self.logger.error(f"Failed to add prompt section: {e}") - # Continue without the prompt section + } + ] def cleanup(self) -> None: """Cleanup when skill is removed or agent shuts down""" diff --git a/signalwire/signalwire/skills/registry.py b/signalwire/signalwire/skills/registry.py index 81b09d56..3d1bce8b 100644 --- a/signalwire/signalwire/skills/registry.py +++ b/signalwire/signalwire/skills/registry.py @@ -321,6 +321,26 @@ def get_all_skills_schema(self) -> dict[str, dict[str, Any]]: # Helper function to add skill to schema def add_skill_to_schema(skill_class: type[SkillBase], source: str) -> None: + """ + Add one skill class's entry to the enclosing ``skills_schema`` dict. + + Reads the class attributes (``SKILL_NAME``, ``SKILL_DESCRIPTION``, + ``SKILL_VERSION``, ``SUPPORTS_MULTIPLE_INSTANCES``, + ``REQUIRED_PACKAGES``, ``REQUIRED_ENV_VARS``) and calls + ``get_parameter_schema()``, treating an ``AttributeError`` from that call + as an empty schema so a skill that predates the method still gets listed. + A class whose ``SKILL_NAME`` is None is skipped entirely. + + Any other exception is logged and swallowed, so one malformed skill + cannot abort the whole registry scan — it is simply absent from the + result. + + Args: + skill_class: The SkillBase subclass to describe. + source: Provenance recorded on the entry ('built-in', 'external', + 'entry_point' or 'registered'). Callers add already-registered + skills first, so a later directory scan does not overwrite them. + """ try: skill_name = skill_class.SKILL_NAME if skill_name is None: diff --git a/signalwire/signalwire/skills/swml_transfer/skill.py b/signalwire/signalwire/skills/swml_transfer/skill.py index fde6f863..1dc27f29 100644 --- a/signalwire/signalwire/skills/swml_transfer/skill.py +++ b/signalwire/signalwire/skills/swml_transfer/skill.py @@ -302,7 +302,7 @@ def get_hints(self) -> list[str]: return hints - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """Return prompt sections to add to agent""" sections = [] diff --git a/signalwire/signalwire/skills/web_search/skill.py b/signalwire/signalwire/skills/web_search/skill.py index 4206488f..a4bb8e11 100644 --- a/signalwire/signalwire/skills/web_search/skill.py +++ b/signalwire/signalwire/skills/web_search/skill.py @@ -941,7 +941,7 @@ def get_global_data(self) -> dict[str, Any]: "quality_filtering": True, } - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """Return prompt sections to add to agent""" return [ { diff --git a/signalwire/signalwire/skills/web_search/skill_improved.py b/signalwire/signalwire/skills/web_search/skill_improved.py index 2625e9a6..1a530d54 100644 --- a/signalwire/signalwire/skills/web_search/skill_improved.py +++ b/signalwire/signalwire/skills/web_search/skill_improved.py @@ -559,7 +559,7 @@ def get_global_data(self) -> dict[str, Any]: "quality_filtering": True, } - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """Return prompt sections to add to agent""" return [ { diff --git a/signalwire/signalwire/skills/web_search/skill_original.py b/signalwire/signalwire/skills/web_search/skill_original.py index 290e6da1..af06f418 100644 --- a/signalwire/signalwire/skills/web_search/skill_original.py +++ b/signalwire/signalwire/skills/web_search/skill_original.py @@ -284,7 +284,7 @@ def get_global_data(self) -> dict[str, Any]: """Return global data for agent context""" return {"web_search_enabled": True, "search_provider": "Google Custom Search"} - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """Return prompt sections to add to agent""" return [ { diff --git a/signalwire/signalwire/skills/wikipedia_search/skill.py b/signalwire/signalwire/skills/wikipedia_search/skill.py index 9fea5351..2af367c1 100644 --- a/signalwire/signalwire/skills/wikipedia_search/skill.py +++ b/signalwire/signalwire/skills/wikipedia_search/skill.py @@ -190,7 +190,7 @@ def search_wiki(self, query: str) -> str: except Exception as e: return f"Error searching Wikipedia: {e!s}" - def get_prompt_sections(self) -> list[dict[str, Any]]: + def _get_prompt_sections(self) -> list[dict[str, Any]]: """ Return additional context for the agent prompt. diff --git a/signalwire/signalwire/utils/schema_utils.py b/signalwire/signalwire/utils/schema_utils.py index 310f0231..feec15f8 100644 --- a/signalwire/signalwire/utils/schema_utils.py +++ b/signalwire/signalwire/utils/schema_utils.py @@ -21,6 +21,11 @@ from signalwire.core.logging_config import get_logger +# Bounds $ref / union following in _closed_key_set so a schema with a +# self-referential $ref cannot spin the resolver. Eight levels is well past +# anything the SWML schema needs (verb body -> $ref -> union branch -> $ref). +_MAX_SCHEMA_RESOLVE_DEPTH = 8 + class SchemaValidationError(Exception): """Raised when SWML schema validation fails.""" @@ -384,7 +389,18 @@ def _validate_verb_lightweight( """ Perform lightweight validation (verb existence + required fields only). - This is the fallback when jsonschema-rs is not available. + There is NO missing-dependency fallback: ``jsonschema_rs`` is imported + unconditionally at module scope, so if it is absent this module fails to + import and nothing here runs. This path is reached only for a PARTIAL + SCHEMA — one whose top-level ``properties`` has no ``sections`` key, so + the verb cannot be wrapped in a full SWML document for deep validation + (see ``_validate_verb_full``) — or when ``_full_validator`` was never + initialized because ``__init__`` did not run (mocked test instances). + + Note that reaching this path does NOT imply + ``full_validation_available`` is False: with a partial schema the + validator is still constructed, so that property reports True while + every verb is nonetheless validated here. Args: verb_name: The name of the verb @@ -409,9 +425,9 @@ def _validate_verb_lightweight( def _verb_top_level_property_names(self, verb_name: str) -> set[str] | None: """Resolve the set of KNOWN top-level property names for a verb's config - object, following a single ``$ref`` (e.g. AI -> AIObject). Returns None - when the verb's config schema is not a closed object-with-properties - (i.e. we cannot enumerate a known-key set, so no shallow check applies).""" + object, following a single ``$ref`` (e.g. AI -> AIObject) and UNIONING the + branches of an ``anyOf``/``oneOf`` union. Returns None only when there is + genuinely no enumerable closed key-set, so no shallow check applies.""" if verb_name not in self.verbs: return None verb_def = self.verbs[verb_name]["definition"] @@ -419,12 +435,69 @@ def _verb_top_level_property_names(self, verb_name: str) -> set[str] | None: body = props.get(verb_name) if not isinstance(body, dict): return None - # Follow a single $ref (AI -> AIObject) to the object that declares the - # verb config's own properties. - if "$ref" in body: - ref_name = body["$ref"].split("/")[-1] - body = self.schema.get("$defs", {}).get(ref_name, {}) - if not isinstance(body, dict) or body.get("type") != "object": + return self._closed_key_set(body, 0) + + def _closed_key_set(self, body: Any, depth: int) -> set[str] | None: + """Resolve ONE schema node to the set of top-level property names it closes + over, returning None when the node has no such enumerable closed key-set. + + Three node shapes are handled, and the union case is the one that matters: + + * ``$ref`` — followed into ``$defs`` and resolved recursively + (ai -> AIObject). + * ``anyOf`` / ``oneOf`` — resolved BRANCH BY BRANCH and UNIONED. Without + this the resolver bailed on the first ``type != "object"`` test, because + a union node carries no ``type`` of its own. That bail silently + DISENGAGED the closed-key check: ``_validate_verb_top_level_keys`` reads + None as "nothing to enforce" and reports valid for any key whatsoever. + Five verbs in the shipped schema are union-shaped — connect, play, + send_sms, sleep, unset — so the check was doing nothing for all of them. + A union's known-key set is the union of its object branches' keys: a + config satisfying the union satisfies SOME branch, so a key belonging to + no branch belongs to no valid document. Non-object branches (sleep's + bare ``integer``, SWMLVar) contribute no keys and are skipped — they + constrain the config to not be an object at all, a different question + from which keys an object config may carry. + * a plain closed object — its own ``properties``. + + ``depth`` bounds ``$ref``/union following so a schema with a + self-referential ``$ref`` cannot spin the resolver. Eight levels is well + past anything the SWML schema needs (verb body -> $ref -> union branch -> + $ref). + """ + if not isinstance(body, dict) or depth > _MAX_SCHEMA_RESOLVE_DEPTH: + return None + + # Follow a $ref (AI -> AIObject) to the node that declares the properties. + ref = body.get("$ref") + if isinstance(ref, str): + ref_name = ref.split("/")[-1] + resolved = self.schema.get("$defs", {}).get(ref_name) + if not isinstance(resolved, dict): + return None + return self._closed_key_set(resolved, depth + 1) + + # A union node: resolve every branch and union the ones that yield a set. + branches = body.get("anyOf") + if not isinstance(branches, list): + branches = body.get("oneOf") + if isinstance(branches, list): + union: set[str] = set() + found = False + for branch in branches: + keys = self._closed_key_set(branch, depth + 1) + if keys is None: + continue + found = True + union |= keys + if not found: + # No branch is a closed object (e.g. unset: string | + # array-of-string). There is no key-set to enforce; the deep + # validator owns this shape. + return None + return union + + if body.get("type") != "object": return None prop_map = body.get("properties") if not isinstance(prop_map, dict): @@ -448,7 +521,9 @@ def _validate_verb_top_level_keys( the full deep schema (which would false-reject legitimate deep emissions such as the ai verb's empty prompt.pom). Used for handler verbs (the ai verb) whose deep shapes the handler owns. A no-op when validation is - disabled or when the verb has no enumerable closed key-set.""" + disabled or when the verb genuinely has no enumerable closed key-set (an + open object such as ``set``, or a union with no object branch such as + ``unset``).""" if not self._validation_enabled: return True, [] if verb_name not in self.verbs: diff --git a/signalwire/signalwire/web/web_service.py b/signalwire/signalwire/web/web_service.py index 880f8fa4..f228902d 100644 --- a/signalwire/signalwire/web/web_service.py +++ b/signalwire/signalwire/web/web_service.py @@ -183,6 +183,22 @@ async def add_security_headers( request: "Request", call_next: "Callable[[Request], Awaitable[Response]]", ) -> "Response": + """Attach security and cache headers to every response. + + Runs after the downstream handler, so it decorates whatever + response came back rather than short-circuiting the request. + + Sets the headers from ``SecurityConfig.get_security_headers`` — + ``X-Content-Type-Options: nosniff``, ``X-Frame-Options: DENY``, + ``X-XSS-Protection: 1; mode=block`` and + ``Referrer-Policy: strict-origin-when-cross-origin``, plus + ``Strict-Transport-Security`` only when the request scheme is + ``https`` AND HSTS is enabled in the security config. Existing + values for those header names are overwritten. + + Additionally, a request path starting with any registered static + directory prefix gets ``Cache-Control: public, max-age=3600``. + """ response = await call_next(request) # Add security headers @@ -204,6 +220,20 @@ async def validate_host( request: "Request", call_next: "Callable[[Request], Awaitable[Response]]", ) -> "Response": + """Reject requests whose Host header is not in the allow-list. + + Host-header/DNS-rebinding guard. Takes the ``Host`` request header, + strips any ``:port`` suffix, and passes the bare hostname to + ``SecurityConfig.should_allow_host``. A host that is not allowed is + answered with ``400 Invalid host`` and the request never reaches + the route. + + Two cases pass through unblocked: a request with no ``Host`` header + at all (the empty string is not checked), and any host when the + allow-list contains the ``"*"`` wildcard, which permits everything. + Otherwise the match is exact string membership in + ``allowed_hosts`` — no suffix or subdomain matching. + """ host = request.headers.get("host", "").split(":")[0] if host and not self.security.should_allow_host(host): return Response(content="Invalid host", status_code=400) @@ -340,6 +370,23 @@ def _setup_routes(self) -> None: @self.app.get("/health") async def health() -> dict[str, Any]: + """Report service health and its effective configuration. + + ``GET /health``. Unauthenticated — it is registered before the + basic-auth dependency is applied to the content routes, so a probe + can reach it without credentials. Host validation and the security + headers still apply, since those are middleware. + + The check is static: it never touches the filesystem or the served + directories, so a ``healthy`` status means the process is up and + serving, not that any configured directory still exists. + + Returns: + A dict with ``status`` (always ``"healthy"``), ``directories`` + (the configured URL path prefixes), ``ssl_enabled``, + ``auth_required`` (whether basic auth is wired up) and + ``directory_browsing``. + """ return { "status": "healthy", "directories": list(self.directories.keys()), diff --git a/tests/conftest.py b/tests/conftest.py index 6f95ed88..cdc1b1d6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,11 +16,10 @@ import shutil from collections.abc import Iterator from pathlib import Path -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import Mock from typing import Any import json import uuid -from datetime import datetime # Add the project root to Python path project_root = Path(__file__).parent.parent @@ -28,10 +27,7 @@ # Import the main classes we'll be testing from signalwire.core.agent_base import AgentBase -from signalwire.core.function_result import FunctionResult from signalwire.core.swaig_function import SWAIGFunction -from signalwire.core.data_map import DataMap -from signalwire.core.contexts import ContextBuilder from signalwire.core.swml_service import SWMLService @@ -51,15 +47,15 @@ def mock_env_vars() -> Iterator[dict[str, str]]: "SIGNALWIRE_API_TOKEN": "test-token", "SIGNALWIRE_SPACE": "test.signalwire.com", "OPENAI_API_KEY": "test-openai-key", - "TEST_ENV_VAR": "test-value" + "TEST_ENV_VAR": "test-value", } - + # Patch os.environ original_environ = os.environ.copy() os.environ.update(env_vars) - + yield env_vars - + # Restore original environment os.environ.clear() os.environ.update(original_environ) @@ -69,13 +65,13 @@ def mock_env_vars() -> Iterator[dict[str, str]]: def sample_agent_config() -> dict[str, Any]: """Sample agent configuration for testing""" return { - 'name': 'test_agent', - 'route': '/test', - 'host': '127.0.0.1', - 'port': 3001, - 'basic_auth': ('test_user', 'test_password'), - 'use_pom': False, # Disable POM to avoid dependency issues in tests - 'suppress_logs': True + "name": "test_agent", + "route": "/test", + "host": "127.0.0.1", + "port": 3001, + "basic_auth": ("test_user", "test_password"), + "use_pom": False, # Disable POM to avoid dependency issues in tests + "suppress_logs": True, } @@ -93,7 +89,7 @@ def mock_agent(mock_env_vars: dict[str, str]) -> AgentBase: port=3001, suppress_logs=True, use_pom=False, - schema_validation=False + schema_validation=False, ) # Mock the session manager to avoid initialization issues @@ -107,22 +103,27 @@ def mock_agent(mock_env_vars: dict[str, str]) -> AgentBase: @pytest.fixture def sample_swaig_function() -> Any: """Sample SWAIG function for testing""" + def test_handler(param1: str, param2: int = 42) -> dict[str, Any]: return {"result": f"Processed {param1} with {param2}"} - + parameters: dict[str, Any] = { "type": "object", "properties": { "param1": {"type": "string", "description": "First parameter"}, - "param2": {"type": "integer", "description": "Second parameter", "default": 42} + "param2": { + "type": "integer", + "description": "Second parameter", + "default": 42, + }, }, - "required": ["param1"] + "required": ["param1"], } return SWAIGFunction( name="test_function", description="A test function", parameters=parameters, - handler=test_handler + handler=test_handler, ) @@ -131,29 +132,19 @@ def sample_post_data() -> dict[str, Any]: """Sample POST data that would come from SignalWire""" return { "function": "test_function", - "argument": { - "parsed": [{"param1": "test_value", "param2": 100}] - }, + "argument": {"parsed": [{"param1": "test_value", "param2": 100}]}, "call_id": str(uuid.uuid4()), - "meta_data": { - "token": "test-token-123" - }, - "global_data": { - "user_id": "test-user-456" - }, - "vars": { - "userVariables": { - "custom_var": "custom_value" - } - }, + "meta_data": {"token": "test-token-123"}, + "global_data": {"user_id": "test-user-456"}, + "vars": {"userVariables": {"custom_var": "custom_value"}}, "call": { "call_id": str(uuid.uuid4()), "state": "created", "direction": "inbound", "type": "webrtc", "from": "+15551234567", - "to": "+15559876543" - } + "to": "+15559876543", + }, } @@ -186,19 +177,15 @@ def sample_swml_document() -> dict[str, Any]: "version": "1.0.0", "sections": { "main": [ - { - "answer": {} - }, + {"answer": {}}, { "ai": { "prompt": "You are a helpful AI assistant.", - "SWAIG": { - "functions": [] - } + "SWAIG": {"functions": []}, } - } + }, ] - } + }, } @@ -206,23 +193,23 @@ def sample_swml_document() -> dict[str, Any]: def mock_skill() -> type: """Mock skill for testing skill manager""" from signalwire.core.skill_base import SkillBase - + class MockSkill(SkillBase): SKILL_NAME = "mock_skill" SKILL_DESCRIPTION = "A mock skill for testing" SKILL_VERSION = "1.0.0" - + def setup(self) -> bool: return True - + def register_tools(self) -> None: self.agent.define_tool( name="mock_tool", description="A mock tool", parameters={"type": "object", "properties": {}}, - handler=lambda: {"result": "mock"} + handler=lambda: {"result": "mock"}, ) - + return MockSkill @@ -246,9 +233,9 @@ def sample_contexts() -> Any: { "name": "detect_greeting", "condition": "contains greeting words", - "action": "respond with greeting" + "action": "respond with greeting", } - ] + ], } ] @@ -256,16 +243,14 @@ def sample_contexts() -> Any: @pytest.fixture def mock_swml_service() -> Any: """Create a mock SWML service for testing (schema validation disabled)""" - service = SWMLService( + return SWMLService( name="test_service", route="/test", host="127.0.0.1", port=3001, - schema_validation=False + schema_validation=False, ) - return service - @pytest.fixture def mock_swaig_function() -> Any: @@ -277,10 +262,10 @@ def mock_swaig_function() -> Any: "type": "object", "properties": { "param1": {"type": "string", "description": "Test parameter"}, - "param2": {"type": "integer", "description": "Test number"} + "param2": {"type": "integer", "description": "Test number"}, }, - "required": ["param1"] - } + "required": ["param1"], + }, } @@ -295,10 +280,7 @@ def mock_post_data() -> Any: "to": "+15559876543", "direction": "inbound", "timestamp": "2024-01-01T12:00:00Z", - "vars": { - "user_id": "test-user-123", - "session_id": "test-session-456" - } + "vars": {"user_id": "test-user-123", "session_id": "test-session-456"}, } @@ -312,62 +294,59 @@ def sample_swml_response() -> Any: { "ai": { "prompt": "You are a helpful assistant", - "SWAIG": { - "functions": [] - } + "SWAIG": {"functions": []}, } } ] - } + }, } # Pytest hooks for better test organization def pytest_configure(config: "pytest.Config") -> None: """Configure pytest with custom markers""" - config.addinivalue_line( - "markers", "unit: mark test as a unit test" - ) - config.addinivalue_line( - "markers", "integration: mark test as an integration test" - ) - config.addinivalue_line( - "markers", "slow: mark test as slow running" - ) - config.addinivalue_line( - "markers", "network: mark test as requiring network access" - ) + config.addinivalue_line("markers", "unit: mark test as a unit test") + config.addinivalue_line("markers", "integration: mark test as an integration test") + config.addinivalue_line("markers", "slow: mark test as slow running") + config.addinivalue_line("markers", "network: mark test as requiring network access") -def pytest_collection_modifyitems(config: "pytest.Config", items: "list[pytest.Item]") -> None: +def pytest_collection_modifyitems( + config: "pytest.Config", items: "list[pytest.Item]" +) -> None: """Automatically mark tests based on their location""" for item in items: # Mark tests in unit/ directory as unit tests if "unit" in str(item.fspath): item.add_marker(pytest.mark.unit) - + # Mark tests in integration/ directory as integration tests if "integration" in str(item.fspath): item.add_marker(pytest.mark.integration) - + # Mark tests that use network fixtures as network tests - if any(fixture in getattr(item, "fixturenames", []) for fixture in ["requests_mock", "httpx_mock"]): + if any( + fixture in getattr(item, "fixturenames", []) + for fixture in ["requests_mock", "httpx_mock"] + ): item.add_marker(pytest.mark.network) # Test utilities class TestUtils: """Utility functions for tests""" - + @staticmethod - def create_mock_response(status_code: int = 200, json_data: "dict[str, Any] | None" = None) -> Any: + def create_mock_response( + status_code: int = 200, json_data: "dict[str, Any] | None" = None + ) -> Any: """Create a mock HTTP response""" response = Mock() response.status_code = status_code response.json.return_value = json_data or {} response.text = json.dumps(json_data or {}) return response - + @staticmethod def assert_swml_structure(swml_dict: "dict[str, Any]") -> None: """Assert that a dictionary has valid SWML structure""" @@ -375,7 +354,7 @@ def assert_swml_structure(swml_dict: "dict[str, Any]") -> None: assert "sections" in swml_dict assert "main" in swml_dict["sections"] assert isinstance(swml_dict["sections"]["main"], list) - + @staticmethod def assert_swaig_function_structure(func_dict: "dict[str, Any]") -> None: """Assert that a dictionary has valid SWAIG function structure""" @@ -387,4 +366,4 @@ def assert_swaig_function_structure(func_dict: "dict[str, Any]") -> None: @pytest.fixture def test_utils() -> type: """Provide test utilities""" - return TestUtils \ No newline at end of file + return TestUtils diff --git a/tests/integration/relay/test_relay_live.py b/tests/integration/relay/test_relay_live.py index d94cda6b..5c6d1643 100644 --- a/tests/integration/relay/test_relay_live.py +++ b/tests/integration/relay/test_relay_live.py @@ -24,7 +24,7 @@ _TOKEN = os.environ.get("SIGNALWIRE_API_TOKEN", "") _HOST = os.environ.get("SIGNALWIRE_SPACE", "") _FROM_NUMBER = os.environ.get("RELAY_FROM_NUMBER", "") # A number on your SW project -_TO_NUMBER = os.environ.get("RELAY_TO_NUMBER", "") # Destination number +_TO_NUMBER = os.environ.get("RELAY_TO_NUMBER", "") # Destination number skip_no_creds = pytest.mark.skipif( not (_PROJECT and _TOKEN), @@ -96,7 +96,14 @@ async def test_dial_and_hangup(self) -> None: client = RelayClient(**kwargs) await client.connect() - devices = [[{"type": "phone", "params": {"to_number": _TO_NUMBER, "from_number": _FROM_NUMBER}}]] + devices = [ + [ + { + "type": "phone", + "params": {"to_number": _TO_NUMBER, "from_number": _FROM_NUMBER}, + } + ] + ] call = await client.dial(devices) assert call.call_id diff --git a/tests/test_examples.py b/tests/test_examples.py index c28a9726..e9acf634 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,4 +1,5 @@ from typing import Any, cast + #!/usr/bin/env python3 """ Test suite for all examples in the examples/ directory. @@ -26,11 +27,19 @@ EXAMPLES_DIR = REPO_ROOT / "examples" -def run_swaig_test(agent_path: Path, *args: str, timeout: int = 30) -> tuple[int, str, str]: +def run_swaig_test( + agent_path: Path, *args: str, timeout: int = 30 +) -> tuple[int, str, str]: """ Run swaig-test on an agent file and return (returncode, stdout, stderr). """ - cmd = [sys.executable, "-m", "signalwire.cli.swaig_test_wrapper", str(agent_path)] + list(args) + cmd = [ + sys.executable, + "-m", + "signalwire.cli.swaig_test_wrapper", + str(agent_path), + *args, + ] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) return result.returncode, result.stdout, result.stderr @@ -44,7 +53,9 @@ def get_swml_json(agent_path: Path) -> dict[str, Any]: """ returncode, stdout, stderr = run_swaig_test(agent_path, "--dump-swml", "--raw") if returncode != 0: - pytest.fail(f"swaig-test failed for {agent_path}:\nstderr: {stderr}\nstdout: {stdout}") + pytest.fail( + f"swaig-test failed for {agent_path}:\nstderr: {stderr}\nstdout: {stdout}" + ) try: return cast("dict[str, Any]", json.loads(stdout)) except json.JSONDecodeError as e: @@ -55,17 +66,17 @@ def list_tools(agent_path: Path) -> list[str]: """ List tools available in an agent. """ - returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") + returncode, stdout, _stderr = run_swaig_test(agent_path, "--list-tools") if returncode != 0: return [] tools = [] - for line in stdout.split('\n'): + for line in stdout.split("\n"): line = line.strip() - if ' - ' in line and not line.startswith('Parameters:'): - parts = line.split(' - ') + if " - " in line and not line.startswith("Parameters:"): + parts = line.split(" - ") if parts: tool_name = parts[0].strip() - if tool_name and not tool_name.startswith('('): + if tool_name and not tool_name.startswith("("): tools.append(tool_name) return tools @@ -103,27 +114,35 @@ def list_tools(agent_path: Path) -> list[str]: class TestBasicAgentExamples: """Test basic agent examples that should load cleanly.""" - @pytest.mark.parametrize("agent_file", [ - "simple_agent.py", - "simple_static_agent.py", - "simple_dynamic_agent.py", - "simple_dynamic_enhanced.py", - "declarative_agent.py", - "faq_bot_agent.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "simple_agent.py", + "simple_static_agent.py", + "simple_dynamic_agent.py", + "simple_dynamic_enhanced.py", + "declarative_agent.py", + "faq_bot_agent.py", + ], + ) def test_basic_agents_load(self, agent_file: str) -> None: """Test basic agent examples can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): pytest.skip(f"Agent file not found: {agent_file}") returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) - @pytest.mark.parametrize("agent_file", [ - "simple_agent.py", - "simple_static_agent.py", - "declarative_agent.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "simple_agent.py", + "simple_static_agent.py", + "declarative_agent.py", + ], + ) def test_basic_agents_generate_valid_swml(self, agent_file: str) -> None: """Test basic agents generate valid SWML.""" agent_path = EXAMPLES_DIR / agent_file @@ -139,18 +158,23 @@ def test_basic_agents_generate_valid_swml(self, agent_file: str) -> None: class TestContextsExamples: """Test context and workflow examples.""" - @pytest.mark.parametrize("agent_file", [ - "contexts_demo.py", - "info_gatherer_example.py", - "dynamic_info_gatherer_example.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "contexts_demo.py", + "info_gatherer_example.py", + "dynamic_info_gatherer_example.py", + ], + ) def test_contexts_agents_load(self, agent_file: str) -> None: """Test context-based agents can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): pytest.skip(f"Agent file not found: {agent_file}") returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) def test_survey_agent_multi_class(self) -> None: """Test survey_agent_example.py with explicit agent class.""" @@ -158,16 +182,23 @@ def test_survey_agent_multi_class(self) -> None: if not agent_path.exists(): pytest.skip("survey_agent_example.py not found") # This file has multiple agent classes - test with specific one - returncode, stdout, stderr = run_swaig_test(agent_path, "--agent-class", "ProductSurveyAgent", "--list-tools") - assert returncode == 0, f"Failed to load ProductSurveyAgent:\nstderr: {stderr}\nstdout: {stdout}" + returncode, stdout, stderr = run_swaig_test( + agent_path, "--agent-class", "ProductSurveyAgent", "--list-tools" + ) + assert returncode == 0, ( + f"Failed to load ProductSurveyAgent:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestDataMapExamples: """Test DataMap examples.""" - @pytest.mark.parametrize("agent_file", [ - "data_map_demo.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "data_map_demo.py", + ], + ) def test_datamap_agents_load(self, agent_file: str) -> None: """Test DataMap agents can be loaded.""" agent_path = EXAMPLES_DIR / agent_file @@ -176,75 +207,104 @@ def test_datamap_agents_load(self, agent_file: str) -> None: if agent_file in NON_STANDARD_EXAMPLES: pytest.skip(f"Skipping {agent_file} - non-standard agent structure") returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestSkillsExamples: """Test skills-related examples.""" - @pytest.mark.parametrize("agent_file", [ - "skills_demo.py", - "wikipedia_demo.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "skills_demo.py", + "wikipedia_demo.py", + ], + ) def test_skills_agents_load(self, agent_file: str) -> None: """Test skills agents can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): pytest.skip(f"Agent file not found: {agent_file}") if agent_file in EXAMPLES_REQUIRING_CREDENTIALS: - pytest.skip(f"Skipping {agent_file} - requires {EXAMPLES_REQUIRING_CREDENTIALS[agent_file]}") + pytest.skip( + f"Skipping {agent_file} - requires {EXAMPLES_REQUIRING_CREDENTIALS[agent_file]}" + ) returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") # Skills may require env vars, so we accept load failure with specific error - if returncode != 0: - # Check if it's a missing env var error (expected for some skills) - if "GOOGLE_SEARCH" in stderr or "API_KEY" in stderr or "env" in stderr.lower(): - pytest.skip(f"Skipping {agent_file} - requires API keys") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + # (a missing env var error is expected for some skills) + if returncode != 0 and ( + "GOOGLE_SEARCH" in stderr or "API_KEY" in stderr or "env" in stderr.lower() + ): + pytest.skip(f"Skipping {agent_file} - requires API keys") + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestWebSearchExamples: """Test web search examples (may require API keys).""" - @pytest.mark.parametrize("agent_file", [ - "web_search_multi_instance_demo.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "web_search_multi_instance_demo.py", + ], + ) def test_web_search_agents_load(self, agent_file: str) -> None: """Test web search agents can be loaded (may skip if no API keys).""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): pytest.skip(f"Agent file not found: {agent_file}") returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - if returncode != 0: - if "GOOGLE_SEARCH" in stderr or "API_KEY" in stderr or "GOOGLE_SEARCH" in stdout: - pytest.skip(f"Skipping {agent_file} - requires Google API keys") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + if returncode != 0 and ( + "GOOGLE_SEARCH" in stderr + or "API_KEY" in stderr + or "GOOGLE_SEARCH" in stdout + ): + pytest.skip(f"Skipping {agent_file} - requires Google API keys") + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestDatasphereExamples: """Test Datasphere examples (may require env vars).""" - @pytest.mark.parametrize("agent_file", [ - "datasphere_serverless_demo.py", - "datasphere_multi_instance_demo.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "datasphere_serverless_demo.py", + "datasphere_multi_instance_demo.py", + ], + ) def test_datasphere_agents_load(self, agent_file: str) -> None: """Test Datasphere agents can be loaded (may skip if no credentials).""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): pytest.skip(f"Agent file not found: {agent_file}") returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - if returncode != 0: - if "SIGNALWIRE" in stderr or "credentials" in stderr.lower() or "SIGNALWIRE" in stdout: - pytest.skip(f"Skipping {agent_file} - requires SignalWire credentials") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + if returncode != 0 and ( + "SIGNALWIRE" in stderr + or "credentials" in stderr.lower() + or "SIGNALWIRE" in stdout + ): + pytest.skip(f"Skipping {agent_file} - requires SignalWire credentials") + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestSWAIGFeaturesExamples: """Test SWAIG feature examples.""" - @pytest.mark.parametrize("agent_file", [ - "swaig_features_agent.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "swaig_features_agent.py", + ], + ) def test_swaig_features_agents_load(self, agent_file: str) -> None: """Test SWAIG feature agents can be loaded.""" agent_path = EXAMPLES_DIR / agent_file @@ -253,15 +313,20 @@ def test_swaig_features_agents_load(self, agent_file: str) -> None: if agent_file in NON_STANDARD_EXAMPLES: pytest.skip(f"Skipping {agent_file} - non-standard agent structure") returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestSWMLServiceExamples: """Test SWML service examples.""" - @pytest.mark.parametrize("agent_file", [ - "swml_service_routing_example.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "swml_service_routing_example.py", + ], + ) def test_swml_service_agents_load(self, agent_file: str) -> None: """Test SWML service examples can be loaded.""" agent_path = EXAMPLES_DIR / agent_file @@ -270,25 +335,34 @@ def test_swml_service_agents_load(self, agent_file: str) -> None: if agent_file in NON_STANDARD_EXAMPLES: pytest.skip(f"Skipping {agent_file} - non-standard agent structure") returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestDeploymentExamples: """Test deployment-related examples.""" - @pytest.mark.parametrize("agent_file", [ - "kubernetes_ready_agent.py", - "custom_path_agent.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "kubernetes_ready_agent.py", + "custom_path_agent.py", + ], + ) def test_deployment_agents_load(self, agent_file: str) -> None: """Test deployment examples can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): pytest.skip(f"Agent file not found: {agent_file}") if agent_file in EXAMPLES_REQUIRING_DEPS: - pytest.skip(f"Skipping {agent_file} - requires {EXAMPLES_REQUIRING_DEPS[agent_file]}") + pytest.skip( + f"Skipping {agent_file} - requires {EXAMPLES_REQUIRING_DEPS[agent_file]}" + ) returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestMultiAgentExamples: @@ -302,8 +376,9 @@ def test_multi_agent_server_load(self) -> None: # Multi-agent files may need --agent flag returncode, stdout, stderr = run_swaig_test(agent_path, "--list-agents") # Should show agents or indicate multiple agents - assert returncode == 0 or "multiple" in stdout.lower() or "agent" in stdout.lower(), \ - f"Failed to list agents:\nstderr: {stderr}\nstdout: {stdout}" + assert ( + returncode == 0 or "multiple" in stdout.lower() or "agent" in stdout.lower() + ), f"Failed to list agents:\nstderr: {stderr}\nstdout: {stdout}" def test_multi_endpoint_agent_load(self) -> None: """Test multi-endpoint agent can be loaded.""" @@ -311,31 +386,41 @@ def test_multi_endpoint_agent_load(self) -> None: if not agent_path.exists(): pytest.skip("multi_endpoint_agent.py not found") returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - assert returncode == 0, f"Failed to load multi_endpoint_agent.py:\nstderr: {stderr}\nstdout: {stdout}" + assert returncode == 0, ( + f"Failed to load multi_endpoint_agent.py:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestPrefabExamples: """Test prefab agent examples.""" - @pytest.mark.parametrize("agent_file", [ - "concierge_agent_example.py", - "receptionist_agent_example.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "concierge_agent_example.py", + "receptionist_agent_example.py", + ], + ) def test_prefab_agents_load(self, agent_file: str) -> None: """Test prefab examples can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): pytest.skip(f"Agent file not found: {agent_file}") returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestDynamicConfigExamples: """Test dynamic configuration examples.""" - @pytest.mark.parametrize("agent_file", [ - "comprehensive_dynamic_agent.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "comprehensive_dynamic_agent.py", + ], + ) def test_dynamic_config_agents_load(self, agent_file: str) -> None: """Test dynamic config examples can be loaded.""" agent_path = EXAMPLES_DIR / agent_file @@ -344,54 +429,72 @@ def test_dynamic_config_agents_load(self, agent_file: str) -> None: if agent_file in NON_STANDARD_EXAMPLES: pytest.skip(f"Skipping {agent_file} - non-standard agent structure") returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestAuthExamples: """Test authentication examples.""" - @pytest.mark.parametrize("agent_file", [ - "env_auth_test.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "env_auth_test.py", + ], + ) def test_auth_agents_load(self, agent_file: str) -> None: """Test auth examples can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): pytest.skip(f"Agent file not found: {agent_file}") if agent_file in EXAMPLES_REQUIRING_CREDENTIALS: - pytest.skip(f"Skipping {agent_file} - requires {EXAMPLES_REQUIRING_CREDENTIALS[agent_file]}") + pytest.skip( + f"Skipping {agent_file} - requires {EXAMPLES_REQUIRING_CREDENTIALS[agent_file]}" + ) returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestSearchExamples: """Test search-related examples (may require index files).""" - @pytest.mark.parametrize("agent_file", [ - "sigmond_simple.py", - "sigmond_native_search.py", - "sigmond_remote_search.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "sigmond_simple.py", + "sigmond_native_search.py", + "sigmond_remote_search.py", + ], + ) def test_search_agents_load(self, agent_file: str) -> None: """Test search agents can be loaded (may skip if no index).""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): pytest.skip(f"Agent file not found: {agent_file}") returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") - if returncode != 0: - if "index" in stderr.lower() or "swsearch" in stderr.lower(): - pytest.skip(f"Skipping {agent_file} - requires search index") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + if returncode != 0 and ( + "index" in stderr.lower() or "swsearch" in stderr.lower() + ): + pytest.skip(f"Skipping {agent_file} - requires search index") + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestBedrockExamples: """Test AWS Bedrock examples (require AWS credentials).""" - @pytest.mark.parametrize("agent_file", [ - "bedrock_agent_run.py", - "bedrock_agent_test.py", - "bedrock_server_test.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "bedrock_agent_run.py", + "bedrock_agent_test.py", + "bedrock_server_test.py", + ], + ) def test_bedrock_agents_load(self, agent_file: str) -> None: """Test Bedrock agents can be loaded (skip if no AWS credentials).""" agent_path = EXAMPLES_DIR / agent_file @@ -404,7 +507,9 @@ def test_bedrock_agents_load(self, agent_file: str) -> None: # bedrock_with_skills.py has a skill loading issue if "Skill" in stdout and "not found" in stdout: pytest.skip(f"Skipping {agent_file} - skill loading issue") - assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + assert returncode == 0, ( + f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" + ) class TestSpecialExamples: @@ -416,11 +521,12 @@ def test_search_server_standalone(self) -> None: if not agent_path.exists(): pytest.skip("search_server_standalone.py not found") # This is a search server, not an agent - may not work with swaig-test - returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") + returncode, _stdout, stderr = run_swaig_test(agent_path, "--list-tools") # Accept if it loads or fails with expected message - if returncode != 0: - if "not an agent" in stderr.lower() or "no agent" in stderr.lower(): - pytest.skip("search_server_standalone.py is not an agent file") + if returncode != 0 and ( + "not an agent" in stderr.lower() or "no agent" in stderr.lower() + ): + pytest.skip("search_server_standalone.py is not an agent file") def test_lambda_handler(self) -> None: """Test lambda handler example.""" @@ -430,9 +536,10 @@ def test_lambda_handler(self) -> None: # This is a test file, may not export an agent directly returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") # Accept load or skip if it's not a standard agent - if returncode != 0: - if "no agent" in stderr.lower() or "not found" in stderr.lower(): - pytest.skip("test_lambda_handler.py doesn't export a standard agent") + if returncode != 0 and ( + "no agent" in stderr.lower() or "not found" in stderr.lower() + ): + pytest.skip("test_lambda_handler.py doesn't export a standard agent") # If we got this far, the swaig-test invocation must have succeeded; # demand a recognisable handler shape, not just "didn't crash". assert returncode == 0, ( @@ -444,12 +551,15 @@ def test_lambda_handler(self) -> None: class TestSWMLGeneration: """Test that key examples generate valid SWML.""" - @pytest.mark.parametrize("agent_file", [ - "simple_agent.py", - "contexts_demo.py", - "swaig_features_agent.py", - "declarative_agent.py", - ]) + @pytest.mark.parametrize( + "agent_file", + [ + "simple_agent.py", + "contexts_demo.py", + "swaig_features_agent.py", + "declarative_agent.py", + ], + ) def test_swml_has_ai_section(self, agent_file: str) -> None: """Test SWML has AI configuration.""" agent_path = EXAMPLES_DIR / agent_file @@ -507,7 +617,9 @@ def test_serverless_swml_generation(self, platform: str) -> None: returncode, stdout, stderr = run_swaig_test( agent_path, "--simulate-serverless", platform, "--dump-swml", "--raw" ) - assert returncode == 0, f"Serverless simulation failed for {platform}:\nstderr: {stderr}" + assert returncode == 0, ( + f"Serverless simulation failed for {platform}:\nstderr: {stderr}" + ) swml = json.loads(stdout) assert "version" in swml, f"Invalid SWML for {platform}" diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py index f67de834..342f4574 100644 --- a/tests/test_mcp_integration.py +++ b/tests/test_mcp_integration.py @@ -1,12 +1,11 @@ from typing import Any + #!/usr/bin/env python3 """Tests for MCP server endpoint and add_mcp_server configuration.""" -import json import pytest from signalwire.core.agent_base import AgentBase from signalwire.core.function_result import FunctionResult -from signalwire.core.mixins.mcp_server_mixin import MCPServerMixin class TestMCPServerMixin: @@ -20,17 +19,17 @@ def _make_agent(self) -> "AgentBase": # Register a tool manually for testing from signalwire.core.swaig_function import SWAIGFunction - def weather_handler(agent_self: Any, args: dict[str, Any], raw: dict[str, Any]) -> Any: + def weather_handler( + agent_self: Any, args: dict[str, Any], raw: dict[str, Any] + ) -> Any: return FunctionResult(f"72F sunny in {args.get('location', 'unknown')}") func = SWAIGFunction( name="get_weather", handler=weather_handler, description="Get the weather for a location", - parameters={ - "location": {"type": "string", "description": "City name"} - }, - required=["location"] + parameters={"location": {"type": "string", "description": "City name"}}, + required=["location"], ) agent._swaig_functions = {"get_weather": func} @@ -51,16 +50,18 @@ def test_build_tool_list(self) -> None: def test_initialize_handshake(self) -> None: """Initialize returns protocol version and capabilities""" agent = self._make_agent() - resp = agent._handle_mcp_request({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "1.0"} + resp = agent._handle_mcp_request( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1.0"}, + }, } - }) + ) assert resp["jsonrpc"] == "2.0" assert resp["id"] == 1 @@ -71,22 +72,18 @@ def test_initialize_handshake(self) -> None: def test_initialized_notification(self) -> None: """notifications/initialized returns empty result""" agent = self._make_agent() - resp = agent._handle_mcp_request({ - "jsonrpc": "2.0", - "method": "notifications/initialized" - }) + resp = agent._handle_mcp_request( + {"jsonrpc": "2.0", "method": "notifications/initialized"} + ) assert "result" in resp def test_tools_list(self) -> None: """tools/list returns registered tools in MCP format""" agent = self._make_agent() - resp = agent._handle_mcp_request({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/list", - "params": {} - }) + resp = agent._handle_mcp_request( + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}} + ) assert resp["id"] == 2 tools = resp["result"]["tools"] @@ -96,18 +93,17 @@ def test_tools_list(self) -> None: def test_tools_call(self) -> None: """tools/call invokes the handler and returns content""" agent = self._make_agent() - resp = agent._handle_mcp_request({ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": { - "name": "get_weather", - "arguments": {"location": "Orlando"} + resp = agent._handle_mcp_request( + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "get_weather", "arguments": {"location": "Orlando"}}, } - }) + ) assert resp["id"] == 3 - assert resp["result"]["isError"] == False + assert not resp["result"]["isError"] content = resp["result"]["content"] assert len(content) == 1 assert content[0]["type"] == "text" @@ -116,12 +112,14 @@ def test_tools_call(self) -> None: def test_tools_call_unknown(self) -> None: """tools/call with unknown tool returns error""" agent = self._make_agent() - resp = agent._handle_mcp_request({ - "jsonrpc": "2.0", - "id": 4, - "method": "tools/call", - "params": {"name": "nonexistent", "arguments": {}} - }) + resp = agent._handle_mcp_request( + { + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": {"name": "nonexistent", "arguments": {}}, + } + ) assert "error" in resp assert resp["error"]["code"] == -32602 @@ -130,12 +128,9 @@ def test_tools_call_unknown(self) -> None: def test_unknown_method(self) -> None: """Unknown method returns method not found error""" agent = self._make_agent() - resp = agent._handle_mcp_request({ - "jsonrpc": "2.0", - "id": 5, - "method": "resources/list", - "params": {} - }) + resp = agent._handle_mcp_request( + {"jsonrpc": "2.0", "id": 5, "method": "resources/list", "params": {}} + ) assert "error" in resp assert resp["error"]["code"] == -32601 @@ -143,22 +138,16 @@ def test_unknown_method(self) -> None: def test_ping(self) -> None: """ping returns empty result""" agent = self._make_agent() - resp = agent._handle_mcp_request({ - "jsonrpc": "2.0", - "id": 6, - "method": "ping" - }) + resp = agent._handle_mcp_request({"jsonrpc": "2.0", "id": 6, "method": "ping"}) assert "result" in resp def test_invalid_jsonrpc_version(self) -> None: """Non-2.0 version returns error""" agent = self._make_agent() - resp = agent._handle_mcp_request({ - "jsonrpc": "1.0", - "id": 7, - "method": "initialize" - }) + resp = agent._handle_mcp_request( + {"jsonrpc": "1.0", "id": 7, "method": "initialize"} + ) assert "error" in resp assert resp["error"]["code"] == -32600 @@ -179,8 +168,7 @@ def test_add_mcp_server_with_headers(self) -> None: """MCP server with auth headers""" agent = AgentBase(name="test", route="/test") agent.add_mcp_server( - "https://mcp.example.com/tools", - headers={"Authorization": "Bearer sk-xxx"} + "https://mcp.example.com/tools", headers={"Authorization": "Bearer sk-xxx"} ) assert agent._mcp_servers[0]["headers"]["Authorization"] == "Bearer sk-xxx" @@ -191,11 +179,13 @@ def test_add_mcp_server_with_resources(self) -> None: agent.add_mcp_server( "https://mcp.example.com/crm", resources=True, - resource_vars={"caller_id": "${caller_id_number}"} + resource_vars={"caller_id": "${caller_id_number}"}, ) - assert agent._mcp_servers[0]["resources"] == True - assert agent._mcp_servers[0]["resource_vars"]["caller_id"] == "${caller_id_number}" + assert agent._mcp_servers[0]["resources"] + assert ( + agent._mcp_servers[0]["resource_vars"]["caller_id"] == "${caller_id_number}" + ) def test_add_multiple_servers(self) -> None: """Multiple MCP servers""" @@ -215,10 +205,10 @@ def test_method_chaining(self) -> None: def test_enable_mcp_server(self) -> None: """enable_mcp_server sets the flag""" agent = AgentBase(name="test", route="/test") - assert agent._mcp_server_enabled == False + assert not agent._mcp_server_enabled result = agent.enable_mcp_server() - assert agent._mcp_server_enabled == True + assert agent._mcp_server_enabled assert result is agent diff --git a/tests/unit/ai_chat/test_client.py b/tests/unit/ai_chat/test_client.py index 036205b5..8a88b1ef 100644 --- a/tests/unit/ai_chat/test_client.py +++ b/tests/unit/ai_chat/test_client.py @@ -25,7 +25,7 @@ ) PROJECT = "proj-1" -TOKEN = "tok-1" # noqa: S105 test placeholder credential, not a real secret +TOKEN = "tok-1" class StubService: diff --git a/tests/unit/cli/test_agent_loader.py b/tests/unit/cli/test_agent_loader.py index 6dba820e..cf7029b5 100644 --- a/tests/unit/cli/test_agent_loader.py +++ b/tests/unit/cli/test_agent_loader.py @@ -26,9 +26,9 @@ import importlib import importlib.util import textwrap -from collections.abc import Iterator # noqa: E402 +from collections.abc import Iterator from pathlib import Path -from unittest.mock import Mock, patch, MagicMock, PropertyMock +from unittest.mock import patch # --------------------------------------------------------------------------- @@ -37,8 +37,10 @@ # try/except resolves successfully. # --------------------------------------------------------------------------- + class _MockSWMLService: """Minimal stand-in for SWMLService used in agent_loader isinstance checks.""" + name = "mock-service" route = "/mock" @@ -51,11 +53,18 @@ def run(self, *a: object, **kw: object) -> None: class _MockAgentBase(_MockSWMLService): """Minimal stand-in for AgentBase (inherits from SWMLService stand-in).""" - _tool_registry: dict[str, object] = {} + + def __init__(self) -> None: + # Per-instance, mirroring the real AgentBase: the production registry is + # built in __init__ and identity-compared per agent (agent_base.py uses + # ``id(agent._tool_registry)``). A class-level dict here would be shared + # by every _MockAgentBase() the suite constructs. + self._tool_registry: dict[str, object] = {} class _MockServiceCapture: """Minimal stand-in for ServiceCapture.""" + pass @@ -90,14 +99,17 @@ class _MockServiceCapture: # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def _patch_module_globals() -> Iterator[None]: """Ensure every test sees our mock classes and the AVAILABLE flags set.""" - with patch.object(agent_loader, "SWMLService", _MockSWMLService), \ - patch.object(agent_loader, "AgentBase", _MockAgentBase), \ - patch.object(agent_loader, "AGENT_BASE_AVAILABLE", True), \ - patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", True), \ - patch.object(agent_loader, "NEW_LOADER_AVAILABLE", True): + with ( + patch.object(agent_loader, "SWMLService", _MockSWMLService), + patch.object(agent_loader, "AgentBase", _MockAgentBase), + patch.object(agent_loader, "AGENT_BASE_AVAILABLE", True), + patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", True), + patch.object(agent_loader, "NEW_LOADER_AVAILABLE", True), + ): yield @@ -112,14 +124,17 @@ def _write_py(tmp_path: Path, filename: str, code: str) -> str: # discover_services_in_file # ============================================================================ + class TestDiscoverServicesInFile: """Tests for the public discover_services_in_file function.""" def test_raises_when_swml_not_available(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "svc.py", "x = 1\n") - with patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", False): - with pytest.raises(ImportError, match="SWMLService not available"): - agent_loader.discover_services_in_file(path) + with ( + patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", False), + pytest.raises(ImportError, match="SWMLService not available"), + ): + agent_loader.discover_services_in_file(path) def test_file_not_found(self, tmp_path: Path) -> None: fake = str(tmp_path / "no_such_file.py") @@ -150,7 +165,9 @@ class Svc: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -176,11 +193,14 @@ def test_finds_subclass(self, tmp_path: Path) -> None: class MySvcClass(_MockSWMLService): """Custom service""" + pass orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -198,7 +218,7 @@ def fake_exec(module: types.ModuleType) -> None: names = [r["name"] for r in results] assert "MySvcClass" in names - cls_entry = [r for r in results if r["name"] == "MySvcClass"][0] + cls_entry = next(r for r in results if r["name"] == "MySvcClass") assert cls_entry["type"] == "class" @@ -206,6 +226,7 @@ def fake_exec(module: types.ModuleType) -> None: # discover_agents_in_file # ============================================================================ + class TestDiscoverAgentsInFile: """Tests for the backward-compat discover_agents_in_file wrapper.""" @@ -224,7 +245,9 @@ def test_filters_to_agents_only(self, tmp_path: Path) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -257,6 +280,7 @@ def test_empty_when_no_agents(self, tmp_path: Path) -> None: # _discover_services_impl # ============================================================================ + class TestDiscoverServicesImpl: """Tests for the internal _discover_services_impl.""" @@ -306,7 +330,9 @@ def test_instance_attributes(self, tmp_path: Path) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -338,6 +364,7 @@ def test_class_not_duplicated_when_instance_exists(self, tmp_path: Path) -> None class MySpecialSvc(_MockSWMLService): """Special""" + pass inst = MySpecialSvc() @@ -346,7 +373,9 @@ class MySpecialSvc(_MockSWMLService): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -363,7 +392,9 @@ def fake_exec(module: types.ModuleType) -> None: with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): results = agent_loader._discover_services_impl(path) - class_entries = [r for r in results if r["name"] == "MySpecialSvc" and r["type"] == "class"] + class_entries = [ + r for r in results if r["name"] == "MySpecialSvc" and r["type"] == "class" + ] assert len(class_entries) == 0 # should be deduplicated def test_agent_instance_has_is_agent_true(self, tmp_path: Path) -> None: @@ -376,7 +407,9 @@ def test_agent_instance_has_is_agent_true(self, tmp_path: Path) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -407,20 +440,27 @@ def test_empty_module_returns_empty_list(self, tmp_path: Path) -> None: # load_service_from_file # ============================================================================ + class TestLoadServiceFromFile: """Tests for the public load_service_from_file function.""" def test_raises_when_swml_not_available(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "s.py", "x = 1\n") - with patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", False): - with pytest.raises(ImportError, match="SWMLService not available"): - agent_loader.load_service_from_file(path) + with ( + patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", False), + pytest.raises(ImportError, match="SWMLService not available"), + ): + agent_loader.load_service_from_file(path) def test_delegates_to_impl(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "s2.py", "x = 1\n") sentinel = _MockSWMLService() - with patch.object(agent_loader, "_load_service_impl", return_value=sentinel) as m: - result = agent_loader.load_service_from_file(path, "ident", prefer_route=False) + with patch.object( + agent_loader, "_load_service_impl", return_value=sentinel + ) as m: + result = agent_loader.load_service_from_file( + path, "ident", prefer_route=False + ) m.assert_called_once_with(path, "ident", False) assert result is sentinel @@ -429,19 +469,24 @@ def test_delegates_to_impl(self, tmp_path: Path) -> None: # load_agent_from_file # ============================================================================ + class TestLoadAgentFromFile: """Tests for the public load_agent_from_file function.""" def test_raises_when_agent_base_not_available(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "a.py", "x = 1\n") - with patch.object(agent_loader, "AGENT_BASE_AVAILABLE", False): - with pytest.raises(ImportError, match="AgentBase not available"): - agent_loader.load_agent_from_file(path) + with ( + patch.object(agent_loader, "AGENT_BASE_AVAILABLE", False), + pytest.raises(ImportError, match="AgentBase not available"), + ): + agent_loader.load_agent_from_file(path) def test_delegates_to_impl_with_prefer_route_false(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "a2.py", "x = 1\n") sentinel = _MockAgentBase() - with patch.object(agent_loader, "_load_service_impl", return_value=sentinel) as m: + with patch.object( + agent_loader, "_load_service_impl", return_value=sentinel + ) as m: result = agent_loader.load_agent_from_file(path, "MyClass") m.assert_called_once_with(path, "MyClass", prefer_route=False) assert result is sentinel @@ -451,6 +496,7 @@ def test_delegates_to_impl_with_prefer_route_false(self, tmp_path: Path) -> None # _load_service_impl # ============================================================================ + class TestLoadServiceImpl: """Tests for the internal _load_service_impl function.""" @@ -483,7 +529,9 @@ def test_prefer_route_finds_instance_by_route(self, tmp_path: Path) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -497,7 +545,9 @@ def fake_exec(module: types.ModuleType) -> None: return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): - result = agent_loader._load_service_impl(path, "/my-route", prefer_route=True) + result = agent_loader._load_service_impl( + path, "/my-route", prefer_route=True + ) assert result is inst def test_prefer_route_not_found_raises(self, tmp_path: Path) -> None: @@ -517,7 +567,9 @@ def test_prefer_route_fallback_to_class_name(self, tmp_path: Path) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -546,7 +598,9 @@ def test_class_name_finds_instance(self, tmp_path: Path) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -576,7 +630,9 @@ def test_class_name_not_valid_service(self, tmp_path: Path) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -589,9 +645,11 @@ def fake_exec(module: types.ModuleType) -> None: spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec - with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): - with pytest.raises(ValueError, match="not a valid SWMLService"): - agent_loader._load_service_impl(path, "NotAService", prefer_route=False) + with ( + patch("importlib.util.spec_from_file_location", side_effect=patched_spec), + pytest.raises(ValueError, match="not a valid SWMLService"), + ): + agent_loader._load_service_impl(path, "NotAService", prefer_route=False) def test_class_name_instantiates_class(self, tmp_path: Path) -> None: """When the identifier is a SWMLService subclass, it should be instantiated.""" @@ -605,7 +663,9 @@ def __init__(self) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -619,7 +679,9 @@ def fake_exec(module: types.ModuleType) -> None: return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): - result = agent_loader._load_service_impl(path, "MySvcClass", prefer_route=False) + result = agent_loader._load_service_impl( + path, "MySvcClass", prefer_route=False + ) assert isinstance(result, MySvcClass) # --- Strategy 1: 'agent' / 'service' variable ----------------------- @@ -634,7 +696,9 @@ def test_strategy1_finds_agent_variable(self, tmp_path: Path) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -661,7 +725,9 @@ def test_strategy1_finds_service_variable(self, tmp_path: Path) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -690,7 +756,9 @@ def test_strategy2_single_instance(self, tmp_path: Path) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -722,7 +790,9 @@ def test_strategy2_multiple_instances_prefers_agent(self, tmp_path: Path) -> Non orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -741,7 +811,9 @@ def fake_exec(module: types.ModuleType) -> None: # Strategy 1 should find 'agent' before Strategy 2 even runs assert result is inst2 - def test_strategy2_multiple_instances_uses_first_when_no_preferred_name(self, tmp_path: Path) -> None: + def test_strategy2_multiple_instances_uses_first_when_no_preferred_name( + self, tmp_path: Path + ) -> None: code = "x = 1\n" path = _write_py(tmp_path, "s2c.py", code) @@ -755,7 +827,9 @@ def test_strategy2_multiple_instances_uses_first_when_no_preferred_name(self, tm orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -787,7 +861,9 @@ def __init__(self) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -821,7 +897,9 @@ def __init__(self) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -835,11 +913,15 @@ def fake_exec(module: types.ModuleType) -> None: spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec - with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): - with pytest.raises(ValueError, match="Multiple service classes found"): - agent_loader._load_service_impl(path) + with ( + patch("importlib.util.spec_from_file_location", side_effect=patched_spec), + pytest.raises(ValueError, match="Multiple service classes found"), + ): + agent_loader._load_service_impl(path) - def test_strategy3_class_instantiation_failure_prints_warning(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + def test_strategy3_class_instantiation_failure_prints_warning( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: """If the single class can't be instantiated, a warning is printed.""" code = "x = 1\n" path = _write_py(tmp_path, "s3c.py", code) @@ -850,7 +932,9 @@ def __init__(self) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -863,9 +947,11 @@ def fake_exec(module: types.ModuleType) -> None: spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec - with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): - with pytest.raises(ValueError, match="No service found"): - agent_loader._load_service_impl(path) + with ( + patch("importlib.util.spec_from_file_location", side_effect=patched_spec), + pytest.raises(ValueError, match="No service found"), + ): + agent_loader._load_service_impl(path) captured = capsys.readouterr() assert "Warning" in captured.out @@ -882,7 +968,9 @@ def test_strategy4_main_returns_service(self, tmp_path: Path) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -913,7 +1001,9 @@ def fake_main() -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -930,7 +1020,9 @@ def fake_exec(module: types.ModuleType) -> None: result = agent_loader._load_service_impl(path) assert result is inst - def test_strategy4_main_exception_prints_warning(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + def test_strategy4_main_exception_prints_warning( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: """If main() raises, a warning is printed and we fall through.""" code = "x = 1\n" path = _write_py(tmp_path, "s4c.py", code) @@ -940,7 +1032,9 @@ def bad_main() -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -953,9 +1047,11 @@ def fake_exec(module: types.ModuleType) -> None: spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec - with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): - with pytest.raises(ValueError, match="No service found"): - agent_loader._load_service_impl(path) + with ( + patch("importlib.util.spec_from_file_location", side_effect=patched_spec), + pytest.raises(ValueError, match="No service found"), + ): + agent_loader._load_service_impl(path) captured = capsys.readouterr() assert "Warning" in captured.out @@ -983,7 +1079,9 @@ def test_load_impl_cleans_sys_path(self, tmp_path: Path) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -1006,36 +1104,50 @@ def fake_exec(module: types.ModuleType) -> None: # Module-level import fallback (AVAILABLE flags) # ============================================================================ + class TestModuleFallbacks: """Tests verifying behaviour when base classes are not importable.""" - def test_discover_services_raises_with_swml_unavailable(self, tmp_path: Path) -> None: + def test_discover_services_raises_with_swml_unavailable( + self, tmp_path: Path + ) -> None: path = _write_py(tmp_path, "f1.py", "x = 1\n") - with patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", False): - with pytest.raises(ImportError): - agent_loader.discover_services_in_file(path) + with ( + patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", False), + pytest.raises(ImportError), + ): + agent_loader.discover_services_in_file(path) def test_load_service_raises_with_swml_unavailable(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "f2.py", "x = 1\n") - with patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", False): - with pytest.raises(ImportError): - agent_loader.load_service_from_file(path) - - def test_load_agent_raises_with_agent_base_unavailable(self, tmp_path: Path) -> None: + with ( + patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", False), + pytest.raises(ImportError), + ): + agent_loader.load_service_from_file(path) + + def test_load_agent_raises_with_agent_base_unavailable( + self, tmp_path: Path + ) -> None: path = _write_py(tmp_path, "f3.py", "x = 1\n") - with patch.object(agent_loader, "AGENT_BASE_AVAILABLE", False): - with pytest.raises(ImportError): - agent_loader.load_agent_from_file(path) + with ( + patch.object(agent_loader, "AGENT_BASE_AVAILABLE", False), + pytest.raises(ImportError), + ): + agent_loader.load_agent_from_file(path) # ============================================================================ # Edge cases # ============================================================================ + class TestEdgeCases: """Miscellaneous edge cases.""" - def test_prefer_route_tries_class_instantiation_for_route(self, tmp_path: Path) -> None: + def test_prefer_route_tries_class_instantiation_for_route( + self, tmp_path: Path + ) -> None: """If no existing instance matches the route, _load_service_impl tries instantiating classes and checking their routes.""" code = "x = 1\n" @@ -1048,7 +1160,9 @@ def __init__(self) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -1062,7 +1176,9 @@ def fake_exec(module: types.ModuleType) -> None: return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): - result = agent_loader._load_service_impl(path, "/special", prefer_route=True) + result = agent_loader._load_service_impl( + path, "/special", prefer_route=True + ) assert isinstance(result, RoutedSvc) def test_class_name_path_instantiation_error(self, tmp_path: Path) -> None: @@ -1076,7 +1192,9 @@ def __init__(self) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -1089,9 +1207,11 @@ def fake_exec(module: types.ModuleType) -> None: spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec - with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): - with pytest.raises(ValueError, match="Failed to instantiate"): - agent_loader._load_service_impl(path, "BadClass", prefer_route=False) + with ( + patch("importlib.util.spec_from_file_location", side_effect=patched_spec), + pytest.raises(ValueError, match="Failed to instantiate"), + ): + agent_loader._load_service_impl(path, "BadClass", prefer_route=False) def test_strategy3_skips_when_module_has_main(self, tmp_path: Path) -> None: """Strategy 3 (class discovery) is skipped when module has main().""" @@ -1110,7 +1230,9 @@ def __init__(self) -> None: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None @@ -1124,14 +1246,18 @@ def fake_exec(module: types.ModuleType) -> None: spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec - with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): + with ( + patch("importlib.util.spec_from_file_location", side_effect=patched_spec), # main() doesn't return a service, so ultimately raises ValueError - with pytest.raises(ValueError, match="No service found"): - agent_loader._load_service_impl(path) + pytest.raises(ValueError, match="No service found"), + ): + agent_loader._load_service_impl(path) # main() should have been called (Strategy 4) assert len(called) == 1 - def test_discover_services_class_with_exception_in_info(self, tmp_path: Path) -> None: + def test_discover_services_class_with_exception_in_info( + self, tmp_path: Path + ) -> None: """If getting class info raises, the exception path in _discover_services_impl still records the class.""" code = "x = 1\n" @@ -1139,11 +1265,14 @@ def test_discover_services_class_with_exception_in_info(self, tmp_path: Path) -> class TrickySvc(_MockSWMLService): """Tricky doc""" + pass orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + def patched_spec( + *args: object, **kwargs: object + ) -> importlib.machinery.ModuleSpec: spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] assert spec is not None assert spec.loader is not None diff --git a/tests/unit/cli/test_build_search.py b/tests/unit/cli/test_build_search.py index 4d0316f0..05b50144 100644 --- a/tests/unit/cli/test_build_search.py +++ b/tests/unit/cli/test_build_search.py @@ -13,19 +13,20 @@ import pytest import sys +import tempfile import types -import json from pathlib import Path -from typing import Any # noqa: E402 -from unittest.mock import Mock, patch, MagicMock, call -from io import StringIO -import argparse +from typing import Any +from unittest.mock import Mock, patch, MagicMock + # Ensure search submodules are patchable even when search deps (nltk, etc.) are missing. # The build_search.py code does local imports from these modules, so @patch needs # them to exist in sys.modules for the patch target to resolve. # Only insert stubs for modules that truly can't be imported. -def _ensure_mock_module(module_path: str, attrs: dict[str, object] | None = None) -> None: +def _ensure_mock_module( + module_path: str, attrs: dict[str, object] | None = None +) -> None: """Register a fake module in sys.modules if the real one isn't importable.""" try: __import__(module_path) @@ -36,170 +37,209 @@ def _ensure_mock_module(module_path: str, attrs: dict[str, object] | None = None setattr(mod, attr_name, attr_val) sys.modules[module_path] = mod -_ensure_mock_module('signalwire.search.index_builder', { - 'IndexBuilder': type('IndexBuilder', (), {}), -}) -_ensure_mock_module('signalwire.search.search_engine', { - 'SearchEngine': type('SearchEngine', (), {}), -}) -_ensure_mock_module('signalwire.search.query_processor', { - 'preprocess_query': lambda *a, **kw: {}, -}) -_ensure_mock_module('signalwire.search.migration', { - 'SearchIndexMigrator': type('SearchIndexMigrator', (), {}), -}) + +_ensure_mock_module( + "signalwire.search.index_builder", + { + "IndexBuilder": type("IndexBuilder", (), {}), + }, +) +_ensure_mock_module( + "signalwire.search.search_engine", + { + "SearchEngine": type("SearchEngine", (), {}), + }, +) +_ensure_mock_module( + "signalwire.search.query_processor", + { + "preprocess_query": lambda *a, **kw: {}, + }, +) +_ensure_mock_module( + "signalwire.search.migration", + { + "SearchIndexMigrator": type("SearchIndexMigrator", (), {}), + }, +) from signalwire.cli.build_search import ( main, validate_command, search_command, - console_entry_point + console_entry_point, ) +# Output dirs for the --output-dir CLI tests. IndexBuilder and Path.mkdir are +# mocked in those tests, so nothing is actually written here; the value only has +# to be a real, process-unique path rather than a hardcoded shared one. These +# are consumed by @patch("sys.argv", ...) decorators, which are evaluated at +# class-body time — the tmp_path fixture does not exist yet at that point. +_CHUNKS_OUT_DIR = tempfile.mkdtemp(prefix="sw_search_chunks_") +_INDEX_OUT_DIR = tempfile.mkdtemp(prefix="sw_search_idx_") + class TestBuildSearchMain: """Test the main build command functionality""" - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs']) + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs"]) def test_basic_build_command(self, mock_builder_class: MagicMock) -> None: """Test basic build command with minimal arguments""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', 'docs'), \ - patch('pathlib.Path.stem', 'docs'), \ - patch('os.path.exists', return_value=True): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("pathlib.Path.name", "docs"), + patch("pathlib.Path.stem", "docs"), + patch("os.path.exists", return_value=True), + ): main() # Verify IndexBuilder was created with defaults mock_builder_class.assert_called_once_with( - model_name='sentence-transformers/all-MiniLM-L6-v2', - chunking_strategy='sentence', + model_name="sentence-transformers/all-MiniLM-L6-v2", + chunking_strategy="sentence", max_sentences_per_chunk=5, chunk_size=50, chunk_overlap=10, split_newlines=None, - index_nlp_backend='nltk', + index_nlp_backend="nltk", verbose=False, semantic_threshold=0.5, topic_threshold=0.3, - backend='sqlite', - connection_string=None + backend="sqlite", + connection_string=None, ) # Verify build_index_from_sources was called mock_builder.build_index_from_sources.assert_called_once() args = mock_builder.build_index_from_sources.call_args - assert len(args[1]['sources']) == 1 - assert args[1]['output_file'] == 'docs.swsearch' - assert args[1]['file_types'] == ['md', 'txt', 'rst'] - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', [ - 'sw-search', './docs', './examples', - '--output', 'custom.swsearch', - '--chunking-strategy', 'sliding', - '--chunk-size', '100', - '--overlap-size', '20', - '--file-types', 'md,py,txt', - '--exclude', '**/test/**,**/__pycache__/**', - '--languages', 'en,es', - '--model', 'custom-model', - '--tags', 'docs,api', - '--verbose', - '--validate' - ]) + assert len(args[1]["sources"]) == 1 + assert args[1]["output_file"] == "docs.swsearch" + assert args[1]["file_types"] == ["md", "txt", "rst"] + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + [ + "sw-search", + "./docs", + "./examples", + "--output", + "custom.swsearch", + "--chunking-strategy", + "sliding", + "--chunk-size", + "100", + "--overlap-size", + "20", + "--file-types", + "md,py,txt", + "--exclude", + "**/test/**,**/__pycache__/**", + "--languages", + "en,es", + "--model", + "custom-model", + "--tags", + "docs,api", + "--verbose", + "--validate", + ], + ) def test_full_build_command(self, mock_builder_class: MagicMock) -> None: """Test build command with all arguments""" mock_builder = Mock() mock_builder_class.return_value = mock_builder mock_builder.validate_index.return_value = { - 'valid': True, - 'chunk_count': 100, - 'file_count': 10, - 'config': {'model': 'custom-model'} + "valid": True, + "chunk_count": 100, + "file_count": 10, + "config": {"model": "custom-model"}, } - - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print') as mock_print: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("os.path.exists", return_value=True), + patch("builtins.print") as mock_print, + ): main() # Verify IndexBuilder was created with custom parameters mock_builder_class.assert_called_once_with( - model_name='custom-model', - chunking_strategy='sliding', + model_name="custom-model", + chunking_strategy="sliding", max_sentences_per_chunk=5, chunk_size=100, chunk_overlap=20, split_newlines=None, - index_nlp_backend='nltk', + index_nlp_backend="nltk", verbose=True, semantic_threshold=0.5, topic_threshold=0.3, - backend='sqlite', - connection_string=None + backend="sqlite", + connection_string=None, ) - + # Verify build_index_from_sources was called with custom parameters args = mock_builder.build_index_from_sources.call_args[1] - assert len(args['sources']) == 2 - assert args['output_file'] == 'custom.swsearch' - assert args['file_types'] == ['md', 'py', 'txt'] - assert args['exclude_patterns'] == ['**/test/**', '**/__pycache__/**'] - assert args['languages'] == ['en', 'es'] - assert args['tags'] == ['docs', 'api'] - + assert len(args["sources"]) == 2 + assert args["output_file"] == "custom.swsearch" + assert args["file_types"] == ["md", "py", "txt"] + assert args["exclude_patterns"] == ["**/test/**", "**/__pycache__/**"] + assert args["languages"] == ["en", "es"] + assert args["tags"] == ["docs", "api"] + # Verify validation was called - mock_builder.validate_index.assert_called_once_with('custom.swsearch') - + mock_builder.validate_index.assert_called_once_with("custom.swsearch") + # Verify verbose output mock_print.assert_any_call("Building search index:") - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', 'README.md']) + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs", "README.md"]) def test_mixed_sources(self, mock_builder_class: MagicMock) -> None: """Test build command with mixed file and directory sources""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - + def mock_exists(self: Path) -> bool: - return str(self) in ['./docs', 'README.md'] + return str(self) in ["./docs", "README.md"] def mock_is_file(self: Path) -> bool: - return str(self) == 'README.md' - - with patch('pathlib.Path.exists', mock_exists), \ - patch('pathlib.Path.is_file', mock_is_file), \ - patch('pathlib.Path.stem', 'sources'), \ - patch('os.path.exists', return_value=True): - + return str(self) == "README.md" + + with ( + patch("pathlib.Path.exists", mock_exists), + patch("pathlib.Path.is_file", mock_is_file), + patch("pathlib.Path.stem", "sources"), + patch("os.path.exists", return_value=True), + ): main() # Should use generic name for multiple sources args = mock_builder.build_index_from_sources.call_args[1] - assert args['output_file'] == 'sources.swsearch' - - @patch('sys.argv', ['sw-search', './nonexistent']) + assert args["output_file"] == "sources.swsearch" + + @patch("sys.argv", ["sw-search", "./nonexistent"]) def test_nonexistent_source(self) -> None: """Test handling of nonexistent sources""" - with patch('pathlib.Path.exists', return_value=False), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: - + with ( + patch("pathlib.Path.exists", return_value=False), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): main() - + assert exc_info.value.code == 1 mock_print.assert_any_call("Error: No valid sources found") - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', './missing']) + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs", "./missing"]) def test_partial_valid_sources(self, mock_builder_class: MagicMock) -> None: """Test handling when some sources are invalid""" mock_builder = Mock() @@ -209,471 +249,566 @@ def test_partial_valid_sources(self, mock_builder_class: MagicMock) -> None: docs_path = Mock() docs_path.exists.return_value = True docs_path.is_file.return_value = False - docs_path.name = 'docs' - docs_path.__str__ = lambda self: './docs' # type: ignore[method-assign, assignment, misc] + docs_path.name = "docs" + docs_path.__str__ = lambda self: "./docs" # type: ignore[method-assign, assignment, misc] missing_path = Mock() missing_path.exists.return_value = False - missing_path.__str__ = lambda self: './missing' # type: ignore[method-assign, assignment, misc] + missing_path.__str__ = lambda self: "./missing" # type: ignore[method-assign, assignment, misc] def mock_path_constructor(path_str: object) -> Mock: - if str(path_str) == './docs': + if str(path_str) == "./docs": return docs_path - elif str(path_str) == './missing': + if str(path_str) == "./missing": return missing_path - else: - # Default mock for other paths - mock_path = Mock() - mock_path.exists.return_value = True - mock_path.__str__ = lambda self: str(path_str) # type: ignore[method-assign, assignment, misc] - return mock_path + # Default mock for other paths + mock_path = Mock() + mock_path.exists.return_value = True + mock_path.__str__ = lambda self: str(path_str) # type: ignore[method-assign, assignment, misc] + return mock_path # Patch Path where it was imported in build_search module - with patch('signalwire.cli.build_search.Path', side_effect=mock_path_constructor), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print') as mock_print: - + with ( + patch( + "signalwire.cli.build_search.Path", side_effect=mock_path_constructor + ), + patch("os.path.exists", return_value=True), + patch("builtins.print") as mock_print, + ): main() # Should warn about missing source but continue - mock_print.assert_any_call("Warning: Source does not exist, skipping: ./missing") + mock_print.assert_any_call( + "Warning: Source does not exist, skipping: ./missing" + ) # Should still build with valid source args = mock_builder.build_index_from_sources.call_args[1] - assert len(args['sources']) == 1 - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './missing1', './missing2']) + assert len(args["sources"]) == 1 + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./missing1", "./missing2"]) def test_all_invalid_sources(self, mock_builder_class: MagicMock) -> None: """Test handling when all sources are invalid""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - - with patch('pathlib.Path.exists', return_value=False), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: - + + with ( + patch("pathlib.Path.exists", return_value=False), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): main() - + assert exc_info.value.code == 1 mock_print.assert_any_call("Error: No valid sources found") - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--output', 'test']) + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs", "--output", "test"]) def test_output_extension_handling(self, mock_builder_class: MagicMock) -> None: """Test automatic addition of .swsearch extension""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('os.path.exists', return_value=True): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("os.path.exists", return_value=True), + ): main() args = mock_builder.build_index_from_sources.call_args[1] - assert args['output_file'] == 'test.swsearch' - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs']) + assert args["output_file"] == "test.swsearch" + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs"]) def test_keyboard_interrupt(self, mock_builder_class: MagicMock) -> None: """Test handling of keyboard interrupt""" mock_builder = Mock() mock_builder_class.return_value = mock_builder mock_builder.build_index_from_sources.side_effect = KeyboardInterrupt() - - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: - + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): main() - + assert exc_info.value.code == 1 mock_print.assert_any_call("\n\nBuild interrupted by user") - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs']) + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs"]) def test_build_error(self, mock_builder_class: MagicMock) -> None: """Test handling of build errors""" mock_builder = Mock() mock_builder_class.return_value = mock_builder mock_builder.build_index_from_sources.side_effect = Exception("Build failed") - - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: - + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): main() - + assert exc_info.value.code == 1 mock_print.assert_any_call("\nError building index: Build failed") - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--validate']) + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs", "--validate"]) def test_validation_failure(self, mock_builder_class: MagicMock) -> None: """Test handling of validation failure""" mock_builder = Mock() mock_builder_class.return_value = mock_builder mock_builder.validate_index.return_value = { - 'valid': False, - 'error': 'Invalid index format' + "valid": False, + "error": "Invalid index format", } - - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', 'docs'), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: - + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("pathlib.Path.name", "docs"), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): main() - + assert exc_info.value.code == 1 - mock_print.assert_any_call("✗ Index validation failed: Invalid index format") + mock_print.assert_any_call( + "✗ Index validation failed: Invalid index format" + ) class TestValidateCommand: """Test the validate command functionality""" - - @patch('argparse.ArgumentParser') + + @patch("argparse.ArgumentParser") def test_validate_nonexistent_file(self, mock_parser_class: MagicMock) -> None: """Test validation of nonexistent file""" # Mock argument parser mock_parser = Mock() mock_parser_class.return_value = mock_parser mock_args = Mock() - mock_args.index_file = 'nonexistent.swsearch' + mock_args.index_file = "nonexistent.swsearch" mock_args.verbose = False mock_parser.parse_args.return_value = mock_args - - with patch('pathlib.Path.exists', return_value=False), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: - + + with ( + patch("pathlib.Path.exists", return_value=False), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): validate_command() - + assert exc_info.value.code == 1 - mock_print.assert_any_call("Error: Index file does not exist: nonexistent.swsearch") + mock_print.assert_any_call( + "Error: Index file does not exist: nonexistent.swsearch" + ) class TestSearchCommand: """Test the search command functionality""" - - @patch('sys.argv', ['search', 'test.swsearch', 'test query']) + + @patch("sys.argv", ["search", "test.swsearch", "test query"]) def test_basic_search(self) -> None: """Test basic search command""" mock_engine = Mock() - mock_engine.get_stats.return_value = {'total_chunks': 100, 'total_files': 10} + mock_engine.get_stats.return_value = {"total_chunks": 100, "total_files": 10} mock_engine.search.return_value = [ { - 'score': 0.95, - 'content': 'Test content', - 'metadata': {'filename': 'test.md', 'section': 'intro'} + "score": 0.95, + "content": "Test content", + "metadata": {"filename": "test.md", "section": "intro"}, } ] - + mock_preprocess_return = { - 'vector': [0.1, 0.2, 0.3], - 'enhanced_text': 'enhanced test query' + "vector": [0.1, 0.2, 0.3], + "enhanced_text": "enhanced test query", } - - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - patch('signalwire.search.search_engine.SearchEngine', return_value=mock_engine), \ - patch('signalwire.search.query_processor.preprocess_query', return_value=mock_preprocess_return): - + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + patch( + "signalwire.search.search_engine.SearchEngine", return_value=mock_engine + ), + patch( + "signalwire.search.query_processor.preprocess_query", + return_value=mock_preprocess_return, + ), + ): search_command() - + mock_print.assert_any_call("Found 1 result(s) for 'test query':") - - @patch('sys.argv', [ - 'search', 'test.swsearch', 'test query', - '--count', '10', - '--distance-threshold', '0.5', - '--tags', 'docs,api', - '--query-nlp-backend', 'spacy', - '--verbose', - '--json' - ]) + + @patch( + "sys.argv", + [ + "search", + "test.swsearch", + "test query", + "--count", + "10", + "--distance-threshold", + "0.5", + "--tags", + "docs,api", + "--query-nlp-backend", + "spacy", + "--verbose", + "--json", + ], + ) def test_full_search_command(self) -> None: """Test search command with all options""" mock_engine = Mock() - mock_engine.get_stats.return_value = {'total_chunks': 100, 'total_files': 10} + mock_engine.get_stats.return_value = {"total_chunks": 100, "total_files": 10} mock_engine.search.return_value = [ { - 'score': 0.95, - 'content': 'Test content', - 'metadata': {'filename': 'test.md', 'tags': ['docs']} + "score": 0.95, + "content": "Test content", + "metadata": {"filename": "test.md", "tags": ["docs"]}, } ] - + mock_preprocess_return = { - 'vector': [0.1, 0.2, 0.3], - 'enhanced_text': 'enhanced test query' + "vector": [0.1, 0.2, 0.3], + "enhanced_text": "enhanced test query", } - - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - patch('signalwire.search.search_engine.SearchEngine', return_value=mock_engine), \ - patch('signalwire.search.query_processor.preprocess_query', return_value=mock_preprocess_return): - + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + patch( + "signalwire.search.search_engine.SearchEngine", return_value=mock_engine + ), + patch( + "signalwire.search.query_processor.preprocess_query", + return_value=mock_preprocess_return, + ), + ): search_command() - + # Should output JSON - printed_calls = [str(call) for call in mock_print.call_args_list if call.args] - printed_output = ''.join(printed_calls) + printed_calls = [ + str(call) for call in mock_print.call_args_list if call.args + ] + printed_output = "".join(printed_calls) assert '"query": "test query"' in printed_output - - @patch('sys.argv', ['search', 'test.swsearch', 'test query', '--no-content']) + + @patch("sys.argv", ["search", "test.swsearch", "test query", "--no-content"]) def test_search_no_content(self) -> None: """Test search command with no content output""" mock_engine = Mock() - mock_engine.get_stats.return_value = {'total_chunks': 100, 'total_files': 10} + mock_engine.get_stats.return_value = {"total_chunks": 100, "total_files": 10} mock_engine.search.return_value = [ { - 'score': 0.95, - 'content': 'Test content that should not be shown', - 'metadata': {'filename': 'test.md'} + "score": 0.95, + "content": "Test content that should not be shown", + "metadata": {"filename": "test.md"}, } ] - + mock_preprocess_return = { - 'vector': [0.1, 0.2, 0.3], - 'enhanced_text': 'test query' + "vector": [0.1, 0.2, 0.3], + "enhanced_text": "test query", } - - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - patch('signalwire.search.search_engine.SearchEngine', return_value=mock_engine), \ - patch('signalwire.search.query_processor.preprocess_query', return_value=mock_preprocess_return): - + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + patch( + "signalwire.search.search_engine.SearchEngine", return_value=mock_engine + ), + patch( + "signalwire.search.query_processor.preprocess_query", + return_value=mock_preprocess_return, + ), + ): search_command() - + # Content should not be printed - printed_output = ''.join([str(call.args[0]) for call in mock_print.call_args_list]) - assert 'Test content that should not be shown' not in printed_output - - @patch('sys.argv', ['search', 'test.swsearch', 'test query']) + printed_output = "".join( + [str(call.args[0]) for call in mock_print.call_args_list] + ) + assert "Test content that should not be shown" not in printed_output + + @patch("sys.argv", ["search", "test.swsearch", "test query"]) def test_search_no_results(self) -> None: """Test search command with no results""" mock_engine = Mock() - mock_engine.get_stats.return_value = {'total_chunks': 100, 'total_files': 10} + mock_engine.get_stats.return_value = {"total_chunks": 100, "total_files": 10} mock_engine.search.return_value = [] - + mock_preprocess_return = { - 'vector': [0.1, 0.2, 0.3], - 'enhanced_text': 'test query' + "vector": [0.1, 0.2, 0.3], + "enhanced_text": "test query", } - - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - patch('signalwire.search.search_engine.SearchEngine', return_value=mock_engine), \ - patch('signalwire.search.query_processor.preprocess_query', return_value=mock_preprocess_return), \ - pytest.raises(SystemExit) as exc_info: - + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + patch( + "signalwire.search.search_engine.SearchEngine", return_value=mock_engine + ), + patch( + "signalwire.search.query_processor.preprocess_query", + return_value=mock_preprocess_return, + ), + pytest.raises(SystemExit) as exc_info, + ): search_command() - + assert exc_info.value.code == 0 mock_print.assert_any_call("No results found for 'test query'") - - @patch('sys.argv', ['search', 'nonexistent.swsearch', 'query']) + + @patch("sys.argv", ["search", "nonexistent.swsearch", "query"]) def test_search_nonexistent_file(self) -> None: """Test search with nonexistent index file""" - with patch('pathlib.Path.exists', return_value=False), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: - + with ( + patch("pathlib.Path.exists", return_value=False), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): search_command() - + assert exc_info.value.code == 1 - mock_print.assert_any_call("Error: Index file does not exist: nonexistent.swsearch") - - @patch('sys.argv', ['search', 'test.swsearch', 'query']) + mock_print.assert_any_call( + "Error: Index file does not exist: nonexistent.swsearch" + ) + + @patch("sys.argv", ["search", "test.swsearch", "query"]) def test_search_import_error(self) -> None: """Test search with missing dependencies""" - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: - + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): # Mock import error for search dependencies - with patch('signalwire.search.search_engine.SearchEngine', side_effect=ImportError("No module")): + with patch( + "signalwire.search.search_engine.SearchEngine", + side_effect=ImportError("No module"), + ): search_command() - + assert exc_info.value.code == 1 - mock_print.assert_any_call("Error: Search functionality not available. Install with: pip install signalwire-sdk[search]") - - @patch('sys.argv', ['search', 'test.swsearch', 'query']) + mock_print.assert_any_call( + "Error: Search functionality not available. Install with: pip install signalwire-sdk[search]" + ) + + @patch("sys.argv", ["search", "test.swsearch", "query"]) def test_search_engine_error(self) -> None: """Test search engine initialization error""" - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - patch('signalwire.search.search_engine.SearchEngine', side_effect=Exception("Engine error")), \ - pytest.raises(SystemExit) as exc_info: - + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + patch( + "signalwire.search.search_engine.SearchEngine", + side_effect=Exception("Engine error"), + ), + pytest.raises(SystemExit) as exc_info, + ): search_command() - + assert exc_info.value.code == 1 mock_print.assert_any_call("Error searching index: Engine error") class TestConsoleEntryPoint: """Test the console entry point functionality""" - - @patch('signalwire.cli.build_search.main') - @patch('sys.argv', ['sw-search', './docs']) + + @patch("signalwire.cli.build_search.main") + @patch("sys.argv", ["sw-search", "./docs"]) def test_console_entry_main(self, mock_main: MagicMock) -> None: """Test console entry point calls main for build command""" console_entry_point() mock_main.assert_called_once() - - @patch('signalwire.cli.build_search.validate_command') - @patch('sys.argv', ['sw-search', 'validate', 'test.swsearch']) + + @patch("signalwire.cli.build_search.validate_command") + @patch("sys.argv", ["sw-search", "validate", "test.swsearch"]) def test_console_entry_validate(self, mock_validate: MagicMock) -> None: """Test console entry point calls validate_command""" console_entry_point() mock_validate.assert_called_once() # Should remove 'validate' from argv - assert 'validate' not in sys.argv - - @patch('signalwire.cli.build_search.search_command') - @patch('sys.argv', ['sw-search', 'search', 'test.swsearch', 'query']) + assert "validate" not in sys.argv + + @patch("signalwire.cli.build_search.search_command") + @patch("sys.argv", ["sw-search", "search", "test.swsearch", "query"]) def test_console_entry_search(self, mock_search: MagicMock) -> None: """Test console entry point calls search_command""" console_entry_point() mock_search.assert_called_once() # Should remove 'search' from argv - assert 'search' not in sys.argv + assert "search" not in sys.argv class TestArgumentParsing: """Test argument parsing edge cases""" - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--chunking-strategy', 'sentence', '--split-newlines', '2']) - def test_sentence_chunking_with_newlines(self, mock_builder_class: MagicMock) -> None: + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + [ + "sw-search", + "./docs", + "--chunking-strategy", + "sentence", + "--split-newlines", + "2", + ], + ) + def test_sentence_chunking_with_newlines( + self, mock_builder_class: MagicMock + ) -> None: """Test sentence chunking with split newlines parameter""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', 'docs'), \ - patch('os.path.exists', return_value=True): - + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("pathlib.Path.name", "docs"), + patch("os.path.exists", return_value=True), + ): main() - + mock_builder_class.assert_called_once_with( - model_name='sentence-transformers/all-MiniLM-L6-v2', - chunking_strategy='sentence', + model_name="sentence-transformers/all-MiniLM-L6-v2", + chunking_strategy="sentence", max_sentences_per_chunk=5, chunk_size=50, chunk_overlap=10, split_newlines=2, - index_nlp_backend='nltk', + index_nlp_backend="nltk", verbose=False, semantic_threshold=0.5, topic_threshold=0.3, - backend='sqlite', - connection_string=None + backend="sqlite", + connection_string=None, ) - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--chunking-strategy', 'paragraph']) + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs", "--chunking-strategy", "paragraph"]) def test_paragraph_chunking(self, mock_builder_class: MagicMock) -> None: """Test paragraph chunking strategy""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', 'docs'), \ - patch('os.path.exists', return_value=True): - + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("pathlib.Path.name", "docs"), + patch("os.path.exists", return_value=True), + ): main() - + mock_builder_class.assert_called_once_with( - model_name='sentence-transformers/all-MiniLM-L6-v2', - chunking_strategy='paragraph', + model_name="sentence-transformers/all-MiniLM-L6-v2", + chunking_strategy="paragraph", max_sentences_per_chunk=5, chunk_size=50, chunk_overlap=10, split_newlines=None, - index_nlp_backend='nltk', + index_nlp_backend="nltk", verbose=False, semantic_threshold=0.5, topic_threshold=0.3, - backend='sqlite', - connection_string=None + backend="sqlite", + connection_string=None, ) - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--chunking-strategy', 'page']) + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs", "--chunking-strategy", "page"]) def test_page_chunking(self, mock_builder_class: MagicMock) -> None: """Test page chunking strategy""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', 'docs'), \ - patch('os.path.exists', return_value=True): - + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("pathlib.Path.name", "docs"), + patch("os.path.exists", return_value=True), + ): main() - + mock_builder_class.assert_called_once_with( - model_name='sentence-transformers/all-MiniLM-L6-v2', - chunking_strategy='page', + model_name="sentence-transformers/all-MiniLM-L6-v2", + chunking_strategy="page", max_sentences_per_chunk=5, chunk_size=50, chunk_overlap=10, split_newlines=None, - index_nlp_backend='nltk', + index_nlp_backend="nltk", verbose=False, semantic_threshold=0.5, topic_threshold=0.3, - backend='sqlite', - connection_string=None + backend="sqlite", + connection_string=None, ) class TestVerboseOutput: """Test verbose output functionality""" - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'sliding']) + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + ["sw-search", "./docs", "--verbose", "--chunking-strategy", "sliding"], + ) def test_verbose_sliding_output(self, mock_builder_class: MagicMock) -> None: """Test verbose output for sliding window strategy""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', 'docs'), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print') as mock_print: - + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("pathlib.Path.name", "docs"), + patch("os.path.exists", return_value=True), + patch("builtins.print") as mock_print, + ): main() - + # Check for sliding window specific output mock_print.assert_any_call(" Chunk size (words): 50") mock_print.assert_any_call(" Overlap size (words): 10") - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'sentence', '--split-newlines', '3']) + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + [ + "sw-search", + "./docs", + "--verbose", + "--chunking-strategy", + "sentence", + "--split-newlines", + "3", + ], + ) def test_verbose_sentence_output(self, mock_builder_class: MagicMock) -> None: """Test verbose output for sentence strategy with newlines""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', 'docs'), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print') as mock_print: - + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("pathlib.Path.name", "docs"), + patch("os.path.exists", return_value=True), + patch("builtins.print") as mock_print, + ): main() - + # Check for sentence specific output mock_print.assert_any_call(" Max sentences per chunk: 5") mock_print.assert_any_call(" Split on newlines: 3") @@ -681,47 +816,55 @@ def test_verbose_sentence_output(self, mock_builder_class: MagicMock) -> None: class TestErrorHandlingEdgeCases: """Test edge cases and error handling""" - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--verbose']) + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs", "--verbose"]) def test_verbose_error_with_traceback(self, mock_builder_class: MagicMock) -> None: """Test verbose error output includes traceback""" mock_builder_class.side_effect = Exception("Detailed error") - - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('builtins.print') as mock_print, \ - patch('traceback.print_exc') as mock_traceback, \ - pytest.raises(SystemExit): - + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("builtins.print"), + patch("traceback.print_exc") as mock_traceback, + pytest.raises(SystemExit), + ): main() - + mock_traceback.assert_called_once() - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['validate', 'test.swsearch', '--verbose']) - def test_validate_verbose_error_with_traceback(self, mock_builder_class: MagicMock) -> None: + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["validate", "test.swsearch", "--verbose"]) + def test_validate_verbose_error_with_traceback( + self, mock_builder_class: MagicMock + ) -> None: """Test verbose validation error includes traceback""" mock_builder_class.side_effect = Exception("Validation detailed error") - - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - patch('traceback.print_exc') as mock_traceback, \ - pytest.raises(SystemExit): - + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print"), + patch("traceback.print_exc") as mock_traceback, + pytest.raises(SystemExit), + ): validate_command() - + mock_traceback.assert_called_once() - - @patch('sys.argv', ['search', 'test.swsearch', 'query', '--verbose']) + + @patch("sys.argv", ["search", "test.swsearch", "query", "--verbose"]) def test_search_verbose_error_with_traceback(self) -> None: """Test verbose search error includes traceback""" - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - patch('traceback.print_exc') as mock_traceback, \ - patch('signalwire.search.search_engine.SearchEngine', side_effect=Exception("Search detailed error")), \ - pytest.raises(SystemExit): - + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print"), + patch("traceback.print_exc") as mock_traceback, + patch( + "signalwire.search.search_engine.SearchEngine", + side_effect=Exception("Search detailed error"), + ), + pytest.raises(SystemExit), + ): search_command() mock_traceback.assert_called_once() @@ -739,403 +882,539 @@ def test_search_verbose_error_with_traceback(self) -> None: class TestConsoleEntryPointExtended: """Additional tests for console_entry_point subcommand routing.""" - @patch('builtins.print') - @patch('sys.argv', ['sw-search', '--help']) + @patch("builtins.print") + @patch("sys.argv", ["sw-search", "--help"]) def test_console_entry_help_flag(self, mock_print: MagicMock) -> None: """Test --help flag shows help text without importing heavy modules.""" console_entry_point() - printed = ''.join(str(c.args[0]) for c in mock_print.call_args_list if c.args) - assert 'Build local search index from documents' in printed + printed = "".join(str(c.args[0]) for c in mock_print.call_args_list if c.args) + assert "Build local search index from documents" in printed - @patch('builtins.print') - @patch('sys.argv', ['sw-search', '-h']) + @patch("builtins.print") + @patch("sys.argv", ["sw-search", "-h"]) def test_console_entry_help_short_flag(self, mock_print: MagicMock) -> None: """Test -h flag shows help text.""" console_entry_point() - printed = ''.join(str(c.args[0]) for c in mock_print.call_args_list if c.args) - assert 'positional arguments' in printed + printed = "".join(str(c.args[0]) for c in mock_print.call_args_list if c.args) + assert "positional arguments" in printed - @patch('signalwire.cli.build_search.remote_command') - @patch('sys.argv', ['sw-search', 'remote', 'http://localhost:8001', 'query']) + @patch("signalwire.cli.build_search.remote_command") + @patch("sys.argv", ["sw-search", "remote", "http://localhost:8001", "query"]) def test_console_entry_remote(self, mock_remote: MagicMock) -> None: """Test console entry point routes to remote_command.""" console_entry_point() mock_remote.assert_called_once() - assert 'remote' not in sys.argv + assert "remote" not in sys.argv - @patch('signalwire.cli.build_search.migrate_command') - @patch('sys.argv', ['sw-search', 'migrate', 'test.swsearch', '--info']) + @patch("signalwire.cli.build_search.migrate_command") + @patch("sys.argv", ["sw-search", "migrate", "test.swsearch", "--info"]) def test_console_entry_migrate(self, mock_migrate: MagicMock) -> None: """Test console entry point routes to migrate_command.""" console_entry_point() mock_migrate.assert_called_once() - assert 'migrate' not in sys.argv + assert "migrate" not in sys.argv class TestMainPgvectorBackend: """Tests for pgvector backend handling in main().""" - @patch('sys.argv', ['sw-search', './docs', '--backend', 'pgvector']) + @patch("sys.argv", ["sw-search", "./docs", "--backend", "pgvector"]) def test_pgvector_requires_connection_string(self) -> None: """--backend pgvector without --connection-string should exit.""" - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): main() assert exc_info.value.code == 1 mock_print.assert_any_call( "Error: --connection-string is required for pgvector backend" ) - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', [ - 'sw-search', './docs', - '--backend', 'pgvector', - '--connection-string', 'postgresql://user:pass@localhost/db', - ]) - def test_pgvector_default_output_single_source(self, mock_builder_class: MagicMock) -> None: + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + [ + "sw-search", + "./docs", + "--backend", + "pgvector", + "--connection-string", + "postgresql://user:pass@localhost/db", + ], + ) + def test_pgvector_default_output_single_source( + self, mock_builder_class: MagicMock + ) -> None: """pgvector single source should use source name as collection name.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', new_callable=lambda: property(lambda self: 'docs')), \ - patch('builtins.print'): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch( + "pathlib.Path.name", new_callable=lambda: property(lambda self: "docs") + ), + patch("builtins.print"), + ): main() call_kw = mock_builder.build_index_from_sources.call_args[1] # pgvector should NOT add .swsearch - assert not call_kw['output_file'].endswith('.swsearch') - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', [ - 'sw-search', './docs', './more', - '--backend', 'pgvector', - '--connection-string', 'postgresql://u:p@localhost/db', - ]) - def test_pgvector_default_output_multi_source(self, mock_builder_class: MagicMock) -> None: + assert not call_kw["output_file"].endswith(".swsearch") + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + [ + "sw-search", + "./docs", + "./more", + "--backend", + "pgvector", + "--connection-string", + "postgresql://u:p@localhost/db", + ], + ) + def test_pgvector_default_output_multi_source( + self, mock_builder_class: MagicMock + ) -> None: """pgvector with multiple sources defaults to 'documents' collection.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('builtins.print'): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("builtins.print"), + ): main() call_kw = mock_builder.build_index_from_sources.call_args[1] - assert call_kw['output_file'] == 'documents' - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', [ - 'sw-search', './docs', - '--backend', 'pgvector', - '--connection-string', 'postgresql://u:p@localhost/db', - ]) + assert call_kw["output_file"] == "documents" + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + [ + "sw-search", + "./docs", + "--backend", + "pgvector", + "--connection-string", + "postgresql://u:p@localhost/db", + ], + ) def test_pgvector_success_message(self, mock_builder_class: MagicMock) -> None: """pgvector success path prints collection info.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('builtins.print') as mock_print: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("builtins.print") as mock_print, + ): main() printed = [str(c) for c in mock_print.call_args_list] - assert any('collection created successfully' in s for s in printed) + assert any("collection created successfully" in s for s in printed) class TestMainOutputConflict: """Tests for --output and --output-dir conflict detection.""" - @patch('sys.argv', ['sw-search', './docs', '--output', 'out.swsearch', '--output-dir', './dir']) + @patch( + "sys.argv", + ["sw-search", "./docs", "--output", "out.swsearch", "--output-dir", "./dir"], + ) def test_output_and_output_dir_conflict(self) -> None: """Specifying both --output and --output-dir should error.""" - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): main() assert exc_info.value.code == 1 - mock_print.assert_any_call("Error: Cannot specify both --output and --output-dir") + mock_print.assert_any_call( + "Error: Cannot specify both --output and --output-dir" + ) class TestMainJsonOutputFormat: """Tests for --output-format json handling in main().""" - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--output-format', 'json']) - def test_json_format_default_output_name(self, mock_builder_class: MagicMock) -> None: + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs", "--output-format", "json"]) + def test_json_format_default_output_name( + self, mock_builder_class: MagicMock + ) -> None: """JSON format without explicit output should default to chunks.json.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder mock_builder._discover_files_from_sources.return_value = [] - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('builtins.print'), \ - patch('builtins.open', MagicMock()): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("builtins.print"), + patch("builtins.open", MagicMock()), + ): main() # builder should have been constructed (JSON mode uses IndexBuilder too) mock_builder_class.assert_called_once() - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', [ - 'sw-search', './docs', - '--output-format', 'json', - '--backend', 'pgvector', - '--connection-string', 'postgresql://u:p@localhost/db', - ]) - def test_json_format_ignores_backend_warning(self, mock_builder_class: MagicMock) -> None: + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + [ + "sw-search", + "./docs", + "--output-format", + "json", + "--backend", + "pgvector", + "--connection-string", + "postgresql://u:p@localhost/db", + ], + ) + def test_json_format_ignores_backend_warning( + self, mock_builder_class: MagicMock + ) -> None: """JSON format with non-sqlite backend should warn.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder mock_builder._discover_files_from_sources.return_value = [] - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('builtins.print') as mock_print, \ - patch('builtins.open', MagicMock()): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("builtins.print") as mock_print, + patch("builtins.open", MagicMock()), + ): main() mock_print.assert_any_call( "Warning: --backend is ignored when using --output-format json" ) - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', [ - 'sw-search', './docs', - '--output-format', 'json', - '--output', 'my_chunks', - ]) - def test_json_format_output_gets_json_extension(self, mock_builder_class: MagicMock) -> None: + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + [ + "sw-search", + "./docs", + "--output-format", + "json", + "--output", + "my_chunks", + ], + ) + def test_json_format_output_gets_json_extension( + self, mock_builder_class: MagicMock + ) -> None: """JSON format output without .json suffix gets one appended via Path.with_suffix.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder mock_builder._discover_files_from_sources.return_value = [] - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('builtins.print') as mock_print, \ - patch('builtins.open', MagicMock()): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("builtins.print") as mock_print, + patch("builtins.open", MagicMock()), + ): main() # The code calls Path(args.output).with_suffix('.json') when no suffix. # With no files discovered, it writes to the single-file output. # Verify that the printed success message contains .json or the open call happened. # Since _discover_files_from_sources returns [], all_chunks is empty, write still occurs. - printed = ''.join(str(c) for c in mock_print.call_args_list) + printed = "".join(str(c) for c in mock_print.call_args_list) # The output file should end in .json - assert '.json' in printed or mock_builder._discover_files_from_sources.called - - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', [ - 'sw-search', './docs', - '--output-format', 'json', - '--output-dir', '/tmp/test_chunks_out', - ]) + assert ".json" in printed or mock_builder._discover_files_from_sources.called + + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + [ + "sw-search", + "./docs", + "--output-format", + "json", + "--output-dir", + _CHUNKS_OUT_DIR, + ], + ) def test_json_format_output_dir_mode(self, mock_builder_class: MagicMock) -> None: """JSON format with --output-dir should process without error.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder mock_builder._discover_files_from_sources.return_value = [] - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('builtins.print') as mock_print: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("builtins.print") as mock_print, + ): main() # With no files discovered, should still complete and print summary - printed = ''.join(str(c) for c in mock_print.call_args_list) - assert 'JSON files' in printed or 'chunks' in printed.lower() + printed = "".join(str(c) for c in mock_print.call_args_list) + assert "JSON files" in printed or "chunks" in printed.lower() class TestMainOutputDirIndexFormat: """Tests for --output-dir with index format.""" - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--output-dir', '/tmp/idx_out']) - def test_output_dir_single_source_sqlite(self, mock_builder_class: MagicMock) -> None: + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs", "--output-dir", _INDEX_OUT_DIR]) + def test_output_dir_single_source_sqlite( + self, mock_builder_class: MagicMock + ) -> None: """Index format with --output-dir and single source auto-names the file.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', new_callable=lambda: property(lambda self: 'docs')), \ - patch('pathlib.Path.mkdir'), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print'): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch( + "pathlib.Path.name", new_callable=lambda: property(lambda self: "docs") + ), + patch("pathlib.Path.mkdir"), + patch("os.path.exists", return_value=True), + patch("builtins.print"), + ): main() call_kw = mock_builder.build_index_from_sources.call_args[1] - assert call_kw['output_file'].endswith('.swsearch') + assert call_kw["output_file"].endswith(".swsearch") - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './a', './b', '--output-dir', '/tmp/idx_out']) - def test_output_dir_multi_source_sqlite(self, mock_builder_class: MagicMock) -> None: + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./a", "./b", "--output-dir", _INDEX_OUT_DIR]) + def test_output_dir_multi_source_sqlite( + self, mock_builder_class: MagicMock + ) -> None: """Index format with --output-dir and multiple sources uses 'combined'.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.mkdir'), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print'): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch("pathlib.Path.mkdir"), + patch("os.path.exists", return_value=True), + patch("builtins.print"), + ): main() call_kw = mock_builder.build_index_from_sources.call_args[1] - assert 'combined' in call_kw['output_file'] + assert "combined" in call_kw["output_file"] class TestMainModelAlias: """Tests for model alias resolution in main().""" - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--model', 'base']) + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs", "--model", "base"]) def test_model_alias_base(self, mock_builder_class: MagicMock) -> None: """Model alias 'base' should resolve to the full model name.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', new_callable=lambda: property(lambda self: 'docs')), \ - patch('os.path.exists', return_value=True): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch( + "pathlib.Path.name", new_callable=lambda: property(lambda self: "docs") + ), + patch("os.path.exists", return_value=True), + ): main() call_kw = mock_builder_class.call_args[1] - assert call_kw['model_name'] == 'sentence-transformers/all-mpnet-base-v2' + assert call_kw["model_name"] == "sentence-transformers/all-mpnet-base-v2" - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--model', 'large']) + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs", "--model", "large"]) def test_model_alias_large(self, mock_builder_class: MagicMock) -> None: """Model alias 'large' should resolve correctly.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', new_callable=lambda: property(lambda self: 'docs')), \ - patch('os.path.exists', return_value=True): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch( + "pathlib.Path.name", new_callable=lambda: property(lambda self: "docs") + ), + patch("os.path.exists", return_value=True), + ): main() call_kw = mock_builder_class.call_args[1] - assert call_kw['model_name'] == 'sentence-transformers/all-mpnet-base-v2' + assert call_kw["model_name"] == "sentence-transformers/all-mpnet-base-v2" - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--model', 'custom-org/my-model']) + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs", "--model", "custom-org/my-model"]) def test_model_full_name_passthrough(self, mock_builder_class: MagicMock) -> None: """Full model name that is not an alias should pass through unchanged.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', new_callable=lambda: property(lambda self: 'docs')), \ - patch('os.path.exists', return_value=True): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch( + "pathlib.Path.name", new_callable=lambda: property(lambda self: "docs") + ), + patch("os.path.exists", return_value=True), + ): main() call_kw = mock_builder_class.call_args[1] - assert call_kw['model_name'] == 'custom-org/my-model' + assert call_kw["model_name"] == "custom-org/my-model" class TestMainVerboseStrategies: """Tests for verbose output across all chunking strategies.""" - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'paragraph']) + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + ["sw-search", "./docs", "--verbose", "--chunking-strategy", "paragraph"], + ) def test_verbose_paragraph(self, mock_builder_class: MagicMock) -> None: mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', new_callable=lambda: property(lambda self: 'docs')), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print') as mock_print: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch( + "pathlib.Path.name", new_callable=lambda: property(lambda self: "docs") + ), + patch("os.path.exists", return_value=True), + patch("builtins.print") as mock_print, + ): main() mock_print.assert_any_call(" Chunking by paragraphs (double newlines)") - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'page']) + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", ["sw-search", "./docs", "--verbose", "--chunking-strategy", "page"] + ) def test_verbose_page(self, mock_builder_class: MagicMock) -> None: mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', new_callable=lambda: property(lambda self: 'docs')), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print') as mock_print: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch( + "pathlib.Path.name", new_callable=lambda: property(lambda self: "docs") + ), + patch("os.path.exists", return_value=True), + patch("builtins.print") as mock_print, + ): main() mock_print.assert_any_call(" Chunking by pages") - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'semantic']) + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + ["sw-search", "./docs", "--verbose", "--chunking-strategy", "semantic"], + ) def test_verbose_semantic(self, mock_builder_class: MagicMock) -> None: mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', new_callable=lambda: property(lambda self: 'docs')), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print') as mock_print: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch( + "pathlib.Path.name", new_callable=lambda: property(lambda self: "docs") + ), + patch("os.path.exists", return_value=True), + patch("builtins.print") as mock_print, + ): main() mock_print.assert_any_call(" Semantic chunking (similarity threshold: 0.5)") - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'topic']) + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", ["sw-search", "./docs", "--verbose", "--chunking-strategy", "topic"] + ) def test_verbose_topic(self, mock_builder_class: MagicMock) -> None: mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', new_callable=lambda: property(lambda self: 'docs')), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print') as mock_print: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch( + "pathlib.Path.name", new_callable=lambda: property(lambda self: "docs") + ), + patch("os.path.exists", return_value=True), + patch("builtins.print") as mock_print, + ): main() mock_print.assert_any_call(" Topic-based chunking (similarity threshold: 0.3)") - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'qa']) + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", ["sw-search", "./docs", "--verbose", "--chunking-strategy", "qa"] + ) def test_verbose_qa(self, mock_builder_class: MagicMock) -> None: mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', new_callable=lambda: property(lambda self: 'docs')), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print') as mock_print: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch( + "pathlib.Path.name", new_callable=lambda: property(lambda self: "docs") + ), + patch("os.path.exists", return_value=True), + patch("builtins.print") as mock_print, + ): main() mock_print.assert_any_call(" QA-optimized chunking") - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'sentence']) + @patch("signalwire.search.index_builder.IndexBuilder") + @patch( + "sys.argv", + ["sw-search", "./docs", "--verbose", "--chunking-strategy", "sentence"], + ) def test_verbose_sentence_no_newlines(self, mock_builder_class: MagicMock) -> None: """Sentence strategy without split-newlines should not print newline line.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', new_callable=lambda: property(lambda self: 'docs')), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print') as mock_print: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch( + "pathlib.Path.name", new_callable=lambda: property(lambda self: "docs") + ), + patch("os.path.exists", return_value=True), + patch("builtins.print") as mock_print, + ): main() mock_print.assert_any_call(" Max sentences per chunk: 5") # split_newlines not set, so this line should NOT appear for c in mock_print.call_args_list: if c.args: - assert 'Split on newlines' not in str(c.args[0]) + assert "Split on newlines" not in str(c.args[0]) class TestMainSingleFileSource: """Tests for single file source naming.""" - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', 'README.md']) - def test_single_file_source_names_output(self, mock_builder_class: MagicMock) -> None: + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "README.md"]) + def test_single_file_source_names_output( + self, mock_builder_class: MagicMock + ) -> None: """Single file source should name output after file stem.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -1144,35 +1423,44 @@ def mock_exists(self: Path) -> bool: return True def mock_is_file(self: Path) -> bool: - return str(self) == 'README.md' - - with patch('pathlib.Path.exists', mock_exists), \ - patch('pathlib.Path.is_file', mock_is_file), \ - patch('pathlib.Path.stem', new_callable=lambda: property(lambda self: 'README')), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.print'): + return str(self) == "README.md" + + with ( + patch("pathlib.Path.exists", mock_exists), + patch("pathlib.Path.is_file", mock_is_file), + patch( + "pathlib.Path.stem", + new_callable=lambda: property(lambda self: "README"), + ), + patch("os.path.exists", return_value=True), + patch("builtins.print"), + ): main() call_kw = mock_builder.build_index_from_sources.call_args[1] - assert call_kw['output_file'] == 'README.swsearch' + assert call_kw["output_file"] == "README.swsearch" class TestMainIndexCreationCheck: """Tests for post-build file existence check.""" - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('sys.argv', ['sw-search', './docs']) + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("sys.argv", ["sw-search", "./docs"]) def test_index_file_not_created(self, mock_builder_class: MagicMock) -> None: """If output file is not created, should exit with error.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.name', new_callable=lambda: property(lambda self: 'docs')), \ - patch('os.path.exists', return_value=False), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.is_file", return_value=False), + patch( + "pathlib.Path.name", new_callable=lambda: property(lambda self: "docs") + ), + patch("os.path.exists", return_value=False), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): main() assert exc_info.value.code == 1 @@ -1184,105 +1472,119 @@ def test_index_file_not_created(self, mock_builder_class: MagicMock) -> None: class TestValidateCommandExtended: """Additional tests for validate_command.""" - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('argparse.ArgumentParser') - def test_validate_success(self, mock_parser_class: MagicMock, mock_builder_class: MagicMock) -> None: + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("argparse.ArgumentParser") + def test_validate_success( + self, mock_parser_class: MagicMock, mock_builder_class: MagicMock + ) -> None: """Successful validation prints valid message.""" mock_parser = Mock() mock_parser_class.return_value = mock_parser mock_args = Mock() - mock_args.index_file = 'test.swsearch' + mock_args.index_file = "test.swsearch" mock_args.verbose = False mock_parser.parse_args.return_value = mock_args mock_builder = Mock() mock_builder_class.return_value = mock_builder mock_builder.validate_index.return_value = { - 'valid': True, - 'chunk_count': 50, - 'file_count': 5, - 'config': {'embedding_model': 'test-model'}, + "valid": True, + "chunk_count": 50, + "file_count": 5, + "config": {"embedding_model": "test-model"}, } - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + ): validate_command() mock_print.assert_any_call("\u2713 Index is valid: test.swsearch") mock_print.assert_any_call(" Chunks: 50") mock_print.assert_any_call(" Files: 5") - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('argparse.ArgumentParser') - def test_validate_success_verbose(self, mock_parser_class: MagicMock, mock_builder_class: MagicMock) -> None: + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("argparse.ArgumentParser") + def test_validate_success_verbose( + self, mock_parser_class: MagicMock, mock_builder_class: MagicMock + ) -> None: """Successful verbose validation prints configuration details.""" mock_parser = Mock() mock_parser_class.return_value = mock_parser mock_args = Mock() - mock_args.index_file = 'test.swsearch' + mock_args.index_file = "test.swsearch" mock_args.verbose = True mock_parser.parse_args.return_value = mock_args mock_builder = Mock() mock_builder_class.return_value = mock_builder mock_builder.validate_index.return_value = { - 'valid': True, - 'chunk_count': 50, - 'file_count': 5, - 'config': {'embedding_model': 'test-model', 'dimensions': 384}, + "valid": True, + "chunk_count": 50, + "file_count": 5, + "config": {"embedding_model": "test-model", "dimensions": 384}, } - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + ): validate_command() mock_print.assert_any_call("\nConfiguration:") mock_print.assert_any_call(" embedding_model: test-model") - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('argparse.ArgumentParser') - def test_validate_failure(self, mock_parser_class: MagicMock, mock_builder_class: MagicMock) -> None: + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("argparse.ArgumentParser") + def test_validate_failure( + self, mock_parser_class: MagicMock, mock_builder_class: MagicMock + ) -> None: """Failed validation should exit with code 1.""" mock_parser = Mock() mock_parser_class.return_value = mock_parser mock_args = Mock() - mock_args.index_file = 'bad.swsearch' + mock_args.index_file = "bad.swsearch" mock_args.verbose = False mock_parser.parse_args.return_value = mock_args mock_builder = Mock() mock_builder_class.return_value = mock_builder mock_builder.validate_index.return_value = { - 'valid': False, - 'error': 'corrupted index', + "valid": False, + "error": "corrupted index", } - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): validate_command() assert exc_info.value.code == 1 - mock_print.assert_any_call( - "\u2717 Index validation failed: corrupted index" - ) + mock_print.assert_any_call("\u2717 Index validation failed: corrupted index") - @patch('signalwire.search.index_builder.IndexBuilder') - @patch('argparse.ArgumentParser') - def test_validate_exception(self, mock_parser_class: MagicMock, mock_builder_class: MagicMock) -> None: + @patch("signalwire.search.index_builder.IndexBuilder") + @patch("argparse.ArgumentParser") + def test_validate_exception( + self, mock_parser_class: MagicMock, mock_builder_class: MagicMock + ) -> None: """Exception during validation should exit with code 1.""" mock_parser = Mock() mock_parser_class.return_value = mock_parser mock_args = Mock() - mock_args.index_file = 'bad.swsearch' + mock_args.index_file = "bad.swsearch" mock_args.verbose = False mock_parser.parse_args.return_value = mock_args mock_builder_class.side_effect = Exception("Cannot read index") - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print"), + pytest.raises(SystemExit) as exc_info, + ): validate_command() assert exc_info.value.code == 1 @@ -1291,347 +1593,473 @@ def test_validate_exception(self, mock_parser_class: MagicMock, mock_builder_cla class TestSearchCommandExtended: """Additional tests for search_command.""" - @patch('sys.argv', ['search', 'test.swsearch']) + @patch("sys.argv", ["search", "test.swsearch"]) def test_search_no_query_no_shell(self) -> None: """Missing query without --shell should exit.""" - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): search_command() assert exc_info.value.code == 1 mock_print.assert_any_call("Error: Query is required unless using --shell mode") - @patch('sys.argv', ['search', 'test.swsearch', 'q', '--keyword-weight', '1.5']) + @patch("sys.argv", ["search", "test.swsearch", "q", "--keyword-weight", "1.5"]) def test_search_keyword_weight_too_high(self) -> None: """keyword-weight > 1.0 should exit.""" - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): search_command() assert exc_info.value.code == 1 - mock_print.assert_any_call("Error: --keyword-weight must be between 0.0 and 1.0") + mock_print.assert_any_call( + "Error: --keyword-weight must be between 0.0 and 1.0" + ) - @patch('sys.argv', ['search', 'test.swsearch', 'q', '--keyword-weight', '-0.1']) + @patch("sys.argv", ["search", "test.swsearch", "q", "--keyword-weight", "-0.1"]) def test_search_keyword_weight_negative(self) -> None: """keyword-weight < 0.0 should exit.""" - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): search_command() assert exc_info.value.code == 1 - mock_print.assert_any_call("Error: --keyword-weight must be between 0.0 and 1.0") + mock_print.assert_any_call( + "Error: --keyword-weight must be between 0.0 and 1.0" + ) - @patch('sys.argv', [ - 'search', 'coll', 'q', - '--backend', 'pgvector', - ]) + @patch( + "sys.argv", + [ + "search", + "coll", + "q", + "--backend", + "pgvector", + ], + ) def test_search_pgvector_requires_connection_string(self) -> None: """pgvector backend without connection string should exit.""" - with patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): search_command() assert exc_info.value.code == 1 mock_print.assert_any_call( "Error: --connection-string is required for pgvector backend" ) - @patch('sys.argv', ['search', 'test.swsearch', 'q', '--model', 'mini']) + @patch("sys.argv", ["search", "test.swsearch", "q", "--model", "mini"]) def test_search_model_alias_resolved(self) -> None: """Model alias in search should resolve to full name.""" mock_engine = Mock() mock_engine.get_stats.return_value = { - 'total_chunks': 10, 'total_files': 1, - 'config': {'embedding_model': 'whatever'}, + "total_chunks": 10, + "total_files": 1, + "config": {"embedding_model": "whatever"}, } mock_engine.search.return_value = [] mock_preprocess = { - 'vector': [0.1], 'enhanced_text': 'q', + "vector": [0.1], + "enhanced_text": "q", } - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print'), \ - patch('signalwire.search.search_engine.SearchEngine', return_value=mock_engine) as mock_se, \ - patch('signalwire.search.query_processor.preprocess_query', return_value=mock_preprocess), \ - pytest.raises(SystemExit): + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print"), + patch( + "signalwire.search.search_engine.SearchEngine", return_value=mock_engine + ) as mock_se, + patch( + "signalwire.search.query_processor.preprocess_query", + return_value=mock_preprocess, + ), + pytest.raises(SystemExit), + ): search_command() # SearchEngine should be called with the resolved model name call_kw = mock_se.call_args[1] - assert call_kw['model'] == 'sentence-transformers/all-MiniLM-L6-v2' + assert call_kw["model"] == "sentence-transformers/all-MiniLM-L6-v2" - @patch('sys.argv', ['search', 'test.swsearch', 'q', '--json', '--no-content']) + @patch("sys.argv", ["search", "test.swsearch", "q", "--json", "--no-content"]) def test_search_json_no_content(self) -> None: """JSON output with --no-content should omit content field.""" mock_engine = Mock() - mock_engine.get_stats.return_value = {'total_chunks': 10, 'total_files': 1} + mock_engine.get_stats.return_value = {"total_chunks": 10, "total_files": 1} mock_engine.search.return_value = [ { - 'score': 0.9, - 'content': 'Hidden content', - 'metadata': {'filename': 'f.md'}, + "score": 0.9, + "content": "Hidden content", + "metadata": {"filename": "f.md"}, } ] - mock_preprocess = {'vector': [0.1], 'enhanced_text': 'q'} - - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - patch('signalwire.search.search_engine.SearchEngine', return_value=mock_engine), \ - patch('signalwire.search.query_processor.preprocess_query', return_value=mock_preprocess): + mock_preprocess = {"vector": [0.1], "enhanced_text": "q"} + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + patch( + "signalwire.search.search_engine.SearchEngine", return_value=mock_engine + ), + patch( + "signalwire.search.query_processor.preprocess_query", + return_value=mock_preprocess, + ), + ): search_command() # Parse the JSON output printed - printed_text = '' + printed_text = "" for c in mock_print.call_args_list: if c.args: printed_text += str(c.args[0]) - assert 'Hidden content' not in printed_text + assert "Hidden content" not in printed_text - @patch('sys.argv', ['search', 'test.swsearch', 'test query', '--tags', 'docs']) + @patch("sys.argv", ["search", "test.swsearch", "test query", "--tags", "docs"]) def test_search_no_results_with_tags(self) -> None: """No results with tags should mention tags in output.""" mock_engine = Mock() - mock_engine.get_stats.return_value = {'total_chunks': 10, 'total_files': 1} + mock_engine.get_stats.return_value = {"total_chunks": 10, "total_files": 1} mock_engine.search.return_value = [] - mock_preprocess = {'vector': [0.1], 'enhanced_text': 'test query'} - - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - patch('signalwire.search.search_engine.SearchEngine', return_value=mock_engine), \ - patch('signalwire.search.query_processor.preprocess_query', return_value=mock_preprocess), \ - pytest.raises(SystemExit): + mock_preprocess = {"vector": [0.1], "enhanced_text": "test query"} + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + patch( + "signalwire.search.search_engine.SearchEngine", return_value=mock_engine + ), + patch( + "signalwire.search.query_processor.preprocess_query", + return_value=mock_preprocess, + ), + pytest.raises(SystemExit), + ): search_command() printed = [str(c) for c in mock_print.call_args_list] - assert any('tags' in s.lower() for s in printed) + assert any("tags" in s.lower() for s in printed) - @patch('sys.argv', ['search', 'test.swsearch', 'q']) + @patch("sys.argv", ["search", "test.swsearch", "q"]) def test_search_result_with_line_numbers_and_tags(self) -> None: """Results with line_start and tags metadata should display them.""" mock_engine = Mock() - mock_engine.get_stats.return_value = {'total_chunks': 10, 'total_files': 1} + mock_engine.get_stats.return_value = {"total_chunks": 10, "total_files": 1} mock_engine.search.return_value = [ { - 'score': 0.9, - 'content': 'content', - 'metadata': { - 'filename': 'f.md', - 'line_start': 10, - 'line_end': 20, - 'tags': ['api', 'docs'], + "score": 0.9, + "content": "content", + "metadata": { + "filename": "f.md", + "line_start": 10, + "line_end": 20, + "tags": ["api", "docs"], }, } ] - mock_preprocess = {'vector': [0.1], 'enhanced_text': 'q'} - - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - patch('signalwire.search.search_engine.SearchEngine', return_value=mock_engine), \ - patch('signalwire.search.query_processor.preprocess_query', return_value=mock_preprocess): + mock_preprocess = {"vector": [0.1], "enhanced_text": "q"} + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + patch( + "signalwire.search.search_engine.SearchEngine", return_value=mock_engine + ), + patch( + "signalwire.search.query_processor.preprocess_query", + return_value=mock_preprocess, + ), + ): search_command() printed = [str(c) for c in mock_print.call_args_list] - assert any('Lines: 10-20' in s for s in printed) - assert any('Tags: api, docs' in s for s in printed) + assert any("Lines: 10-20" in s for s in printed) + assert any("Tags: api, docs" in s for s in printed) - @patch('sys.argv', ['search', 'test.swsearch', 'q']) + @patch("sys.argv", ["search", "test.swsearch", "q"]) def test_search_long_content_truncated(self) -> None: """Content longer than 500 chars should be truncated in non-verbose mode.""" - long_content = 'A' * 600 + long_content = "A" * 600 mock_engine = Mock() - mock_engine.get_stats.return_value = {'total_chunks': 10, 'total_files': 1} + mock_engine.get_stats.return_value = {"total_chunks": 10, "total_files": 1} mock_engine.search.return_value = [ { - 'score': 0.9, - 'content': long_content, - 'metadata': {'filename': 'f.md'}, + "score": 0.9, + "content": long_content, + "metadata": {"filename": "f.md"}, } ] - mock_preprocess = {'vector': [0.1], 'enhanced_text': 'q'} - - with patch('pathlib.Path.exists', return_value=True), \ - patch('builtins.print') as mock_print, \ - patch('signalwire.search.search_engine.SearchEngine', return_value=mock_engine), \ - patch('signalwire.search.query_processor.preprocess_query', return_value=mock_preprocess): + mock_preprocess = {"vector": [0.1], "enhanced_text": "q"} + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.print") as mock_print, + patch( + "signalwire.search.search_engine.SearchEngine", return_value=mock_engine + ), + patch( + "signalwire.search.query_processor.preprocess_query", + return_value=mock_preprocess, + ), + ): search_command() - printed = ''.join(str(c) for c in mock_print.call_args_list) - assert '...' in printed + printed = "".join(str(c) for c in mock_print.call_args_list) + assert "..." in printed class TestMigrateCommand: """Tests for migrate_command.""" - @patch('sys.argv', ['migrate', '--info', 'test.swsearch']) + @patch("sys.argv", ["migrate", "--info", "test.swsearch"]) def test_migrate_info_success(self) -> None: """--info flag should display index information.""" mock_migrator = Mock() mock_migrator.get_index_info.return_value = { - 'type': 'sqlite', - 'total_chunks': 100, - 'total_files': 10, - 'config': { - 'embedding_model': 'test-model', - 'embedding_dimensions': 384, - 'created_at': '2025-01-01', + "type": "sqlite", + "total_chunks": 100, + "total_files": 10, + "config": { + "embedding_model": "test-model", + "embedding_dimensions": 384, + "created_at": "2025-01-01", }, } - with patch('signalwire.search.migration.SearchIndexMigrator', return_value=mock_migrator), \ - patch('builtins.print') as mock_print: + with ( + patch( + "signalwire.search.migration.SearchIndexMigrator", + return_value=mock_migrator, + ), + patch("builtins.print") as mock_print, + ): migrate_command() mock_print.assert_any_call("Index Information: test.swsearch") mock_print.assert_any_call(" Total chunks: 100") - @patch('sys.argv', ['migrate', '--info']) + @patch("sys.argv", ["migrate", "--info"]) def test_migrate_info_no_source(self) -> None: """--info without source should exit.""" - with patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): migrate_command() assert exc_info.value.code == 1 mock_print.assert_any_call("Error: Source index required with --info") - @patch('sys.argv', ['migrate']) + @patch("sys.argv", ["migrate"]) def test_migrate_no_source(self) -> None: """No source should exit.""" - with patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): migrate_command() assert exc_info.value.code == 1 mock_print.assert_any_call("Error: Source index required for migration") - @patch('sys.argv', ['migrate', 'test.swsearch']) + @patch("sys.argv", ["migrate", "test.swsearch"]) def test_migrate_no_direction(self) -> None: """No migration direction should exit.""" - with patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): migrate_command() assert exc_info.value.code == 1 mock_print.assert_any_call( "Error: Must specify migration direction (--to-pgvector or --to-sqlite)" ) - @patch('sys.argv', ['migrate', 'test.swsearch', '--to-pgvector']) + @patch("sys.argv", ["migrate", "test.swsearch", "--to-pgvector"]) def test_migrate_to_pgvector_no_connection_string(self) -> None: """to-pgvector without connection string should exit.""" mock_migrator = Mock() - with patch('signalwire.search.migration.SearchIndexMigrator', return_value=mock_migrator), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch( + "signalwire.search.migration.SearchIndexMigrator", + return_value=mock_migrator, + ), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): migrate_command() assert exc_info.value.code == 1 mock_print.assert_any_call( "Error: --connection-string required for pgvector migration" ) - @patch('sys.argv', [ - 'migrate', 'test.swsearch', '--to-pgvector', - '--connection-string', 'postgresql://u:p@localhost/db', - ]) + @patch( + "sys.argv", + [ + "migrate", + "test.swsearch", + "--to-pgvector", + "--connection-string", + "postgresql://u:p@localhost/db", + ], + ) def test_migrate_to_pgvector_no_collection_name(self) -> None: """to-pgvector without collection name should exit.""" mock_migrator = Mock() - with patch('signalwire.search.migration.SearchIndexMigrator', return_value=mock_migrator), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch( + "signalwire.search.migration.SearchIndexMigrator", + return_value=mock_migrator, + ), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): migrate_command() assert exc_info.value.code == 1 mock_print.assert_any_call( "Error: --collection-name required for pgvector migration" ) - @patch('sys.argv', [ - 'migrate', 'test.swsearch', '--to-pgvector', - '--connection-string', 'postgresql://u:p@localhost/db', - '--collection-name', 'my_coll', - ]) + @patch( + "sys.argv", + [ + "migrate", + "test.swsearch", + "--to-pgvector", + "--connection-string", + "postgresql://u:p@localhost/db", + "--collection-name", + "my_coll", + ], + ) def test_migrate_to_pgvector_success(self) -> None: """Successful pgvector migration should print success message.""" mock_migrator = Mock() mock_migrator.migrate_sqlite_to_pgvector.return_value = { - 'chunks_migrated': 50, - 'errors': 0, + "chunks_migrated": 50, + "errors": 0, } - with patch('signalwire.search.migration.SearchIndexMigrator', return_value=mock_migrator), \ - patch('builtins.print') as mock_print: + with ( + patch( + "signalwire.search.migration.SearchIndexMigrator", + return_value=mock_migrator, + ), + patch("builtins.print") as mock_print, + ): migrate_command() printed = [str(c) for c in mock_print.call_args_list] - assert any('Migration completed successfully' in s for s in printed) + assert any("Migration completed successfully" in s for s in printed) - @patch('sys.argv', ['migrate', 'test.swsearch', '--to-sqlite']) + @patch("sys.argv", ["migrate", "test.swsearch", "--to-sqlite"]) def test_migrate_to_sqlite_not_implemented(self) -> None: """to-sqlite should report not implemented and exit.""" mock_migrator = Mock() - with patch('signalwire.search.migration.SearchIndexMigrator', return_value=mock_migrator), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch( + "signalwire.search.migration.SearchIndexMigrator", + return_value=mock_migrator, + ), + patch("builtins.print"), + pytest.raises(SystemExit) as exc_info, + ): migrate_command() assert exc_info.value.code == 1 - @patch('sys.argv', [ - 'migrate', 'test.swsearch', '--to-pgvector', - '--connection-string', 'postgresql://u:p@localhost/db', - '--collection-name', 'coll', - ]) + @patch( + "sys.argv", + [ + "migrate", + "test.swsearch", + "--to-pgvector", + "--connection-string", + "postgresql://u:p@localhost/db", + "--collection-name", + "coll", + ], + ) def test_migrate_exception(self) -> None: """Exception during migration should exit with code 1.""" mock_migrator = Mock() mock_migrator.migrate_sqlite_to_pgvector.side_effect = Exception("DB error") - with patch('signalwire.search.migration.SearchIndexMigrator', return_value=mock_migrator), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch( + "signalwire.search.migration.SearchIndexMigrator", + return_value=mock_migrator, + ), + patch("builtins.print"), + pytest.raises(SystemExit) as exc_info, + ): migrate_command() assert exc_info.value.code == 1 - @patch('sys.argv', ['migrate', '--info', 'test.swsearch', '--verbose']) + @patch("sys.argv", ["migrate", "--info", "test.swsearch", "--verbose"]) def test_migrate_info_verbose(self) -> None: """--info --verbose should print full config.""" mock_migrator = Mock() mock_migrator.get_index_info.return_value = { - 'type': 'sqlite', - 'total_chunks': 100, - 'total_files': 10, - 'config': { - 'embedding_model': 'test-model', - 'embedding_dimensions': 384, - 'created_at': '2025-01-01', + "type": "sqlite", + "total_chunks": 100, + "total_files": 10, + "config": { + "embedding_model": "test-model", + "embedding_dimensions": 384, + "created_at": "2025-01-01", }, } - with patch('signalwire.search.migration.SearchIndexMigrator', return_value=mock_migrator), \ - patch('builtins.print') as mock_print: + with ( + patch( + "signalwire.search.migration.SearchIndexMigrator", + return_value=mock_migrator, + ), + patch("builtins.print") as mock_print, + ): migrate_command() mock_print.assert_any_call("\n Full configuration:") - @patch('sys.argv', ['migrate', '--info', 'test.swsearch']) + @patch("sys.argv", ["migrate", "--info", "test.swsearch"]) def test_migrate_info_exception(self) -> None: """Exception in info mode should exit with code 1.""" - with patch('signalwire.search.migration.SearchIndexMigrator', side_effect=Exception("fail")), \ - patch('builtins.print'), \ - pytest.raises(SystemExit) as exc_info: + with ( + patch( + "signalwire.search.migration.SearchIndexMigrator", + side_effect=Exception("fail"), + ), + patch("builtins.print"), + pytest.raises(SystemExit) as exc_info, + ): migrate_command() assert exc_info.value.code == 1 - @patch('sys.argv', ['migrate', '--info', 'test.swsearch']) + @patch("sys.argv", ["migrate", "--info", "test.swsearch"]) def test_migrate_info_unknown_type(self) -> None: """Info with unknown index type should print 'Unable to determine'.""" mock_migrator = Mock() mock_migrator.get_index_info.return_value = { - 'type': 'unknown', + "type": "unknown", } - with patch('signalwire.search.migration.SearchIndexMigrator', return_value=mock_migrator), \ - patch('builtins.print') as mock_print: + with ( + patch( + "signalwire.search.migration.SearchIndexMigrator", + return_value=mock_migrator, + ), + patch("builtins.print") as mock_print, + ): migrate_command() mock_print.assert_any_call(" Unable to determine index type") @@ -1642,7 +2070,8 @@ def _make_mock_requests_module( ) -> types.ModuleType: """Create a mock requests module with real exception classes for except clauses.""" import requests as real_requests - mock_mod = types.ModuleType('requests') + + mock_mod = types.ModuleType("requests") mock_mod.ConnectionError = real_requests.ConnectionError # type: ignore[attr-defined] mock_mod.Timeout = real_requests.Timeout # type: ignore[attr-defined] mock_mod.RequestException = real_requests.RequestException # type: ignore[attr-defined] @@ -1657,236 +2086,310 @@ def _make_mock_requests_module( class TestRemoteCommand: """Tests for remote_command.""" - @patch('sys.argv', ['remote', 'localhost:8001', 'query', '--index-name', 'docs']) + @patch("sys.argv", ["remote", "localhost:8001", "query", "--index-name", "docs"]) def test_endpoint_http_prefix_added(self) -> None: """Endpoint without http:// should get it prepended.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { - 'results': [{'score': 0.9, 'content': 'x', 'metadata': {'filename': 'f'}}] + "results": [{"score": 0.9, "content": "x", "metadata": {"filename": "f"}}] } mock_requests = _make_mock_requests_module(post_return=mock_response) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print'): + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print"), + ): remote_command() call_args = mock_requests.post.call_args - assert call_args[0][0].startswith('http://') - assert call_args[0][0].endswith('/search') + assert call_args[0][0].startswith("http://") + assert call_args[0][0].endswith("/search") - @patch('sys.argv', ['remote', 'http://localhost:8001/', 'query', '--index-name', 'docs']) + @patch( + "sys.argv", + ["remote", "http://localhost:8001/", "query", "--index-name", "docs"], + ) def test_endpoint_trailing_slash(self) -> None: """Endpoint with trailing slash should append 'search' correctly.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { - 'results': [{'score': 0.9, 'content': 'x', 'metadata': {'filename': 'f'}}] + "results": [{"score": 0.9, "content": "x", "metadata": {"filename": "f"}}] } mock_requests = _make_mock_requests_module(post_return=mock_response) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print'): + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print"), + ): remote_command() call_args = mock_requests.post.call_args url = call_args[0][0] - assert url == 'http://localhost:8001/search' + assert url == "http://localhost:8001/search" - @patch('sys.argv', ['remote', 'http://localhost:8001/search', 'query', '--index-name', 'docs']) + @patch( + "sys.argv", + ["remote", "http://localhost:8001/search", "query", "--index-name", "docs"], + ) def test_endpoint_already_has_search(self) -> None: """Endpoint already ending with /search should not double-append.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { - 'results': [{'score': 0.9, 'content': 'x', 'metadata': {'filename': 'f'}}] + "results": [{"score": 0.9, "content": "x", "metadata": {"filename": "f"}}] } mock_requests = _make_mock_requests_module(post_return=mock_response) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print'): + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print"), + ): remote_command() call_args = mock_requests.post.call_args url = call_args[0][0] - assert url == 'http://localhost:8001/search' - assert not url.endswith('/search/search') + assert url == "http://localhost:8001/search" + assert not url.endswith("/search/search") - @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) + @patch( + "sys.argv", ["remote", "http://localhost:8001", "query", "--index-name", "docs"] + ) def test_remote_requests_import_error(self) -> None: """Missing requests library should exit with helpful message.""" import builtins + original_import = builtins.__import__ def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: - if name == 'requests': + if name == "requests": raise ImportError("No module named 'requests'") return original_import(name, *args, **kwargs) - with patch('builtins.__import__', side_effect=mock_import), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch("builtins.__import__", side_effect=mock_import), + patch("builtins.print"), + pytest.raises(SystemExit) as exc_info, + ): remote_command() assert exc_info.value.code == 1 - @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) + @patch( + "sys.argv", ["remote", "http://localhost:8001", "query", "--index-name", "docs"] + ) def test_remote_404_response(self) -> None: """404 response should print error and exit.""" mock_response = Mock() mock_response.status_code = 404 - mock_response.json.return_value = {'detail': 'Index not found'} + mock_response.json.return_value = {"detail": "Index not found"} mock_requests = _make_mock_requests_module(post_return=mock_response) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): remote_command() assert exc_info.value.code == 1 mock_print.assert_any_call("Error: Index not found") - @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) + @patch( + "sys.argv", ["remote", "http://localhost:8001", "query", "--index-name", "docs"] + ) def test_remote_500_response(self) -> None: """500 response should print error and exit.""" mock_response = Mock() mock_response.status_code = 500 - mock_response.json.return_value = {'detail': 'Internal error'} + mock_response.json.return_value = {"detail": "Internal error"} mock_requests = _make_mock_requests_module(post_return=mock_response) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print"), + pytest.raises(SystemExit) as exc_info, + ): remote_command() assert exc_info.value.code == 1 - @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) + @patch( + "sys.argv", ["remote", "http://localhost:8001", "query", "--index-name", "docs"] + ) def test_remote_connection_error(self) -> None: """Connection error should print helpful message and exit.""" import requests as real_requests + mock_requests = _make_mock_requests_module( post_side_effect=real_requests.ConnectionError("Refused") ) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): remote_command() assert exc_info.value.code == 1 printed = [str(c) for c in mock_print.call_args_list] - assert any('Could not connect' in s for s in printed) - - @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs', '--timeout', '5']) + assert any("Could not connect" in s for s in printed) + + @patch( + "sys.argv", + [ + "remote", + "http://localhost:8001", + "query", + "--index-name", + "docs", + "--timeout", + "5", + ], + ) def test_remote_timeout(self) -> None: """Timeout should print timeout message and exit.""" import requests as real_requests + mock_requests = _make_mock_requests_module( post_side_effect=real_requests.Timeout("timed out") ) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): remote_command() assert exc_info.value.code == 1 printed = [str(c) for c in mock_print.call_args_list] - assert any('timed out' in s.lower() for s in printed) - - @patch('sys.argv', [ - 'remote', 'http://localhost:8001', 'query', - '--index-name', 'docs', '--tags', 'a,b', '--verbose', - ]) + assert any("timed out" in s.lower() for s in printed) + + @patch( + "sys.argv", + [ + "remote", + "http://localhost:8001", + "query", + "--index-name", + "docs", + "--tags", + "a,b", + "--verbose", + ], + ) def test_remote_verbose_with_tags(self) -> None: """Verbose mode with tags should print payload.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { - 'results': [{'score': 0.9, 'content': 'x', 'metadata': {'filename': 'f'}}] + "results": [{"score": 0.9, "content": "x", "metadata": {"filename": "f"}}] } mock_requests = _make_mock_requests_module(post_return=mock_response) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print') as mock_print: + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print"), + ): remote_command() # Payload should include tags call_kw = mock_requests.post.call_args - payload = call_kw[1].get('json') + payload = call_kw[1].get("json") assert payload is not None - assert payload['tags'] == ['a', 'b'] - - @patch('sys.argv', [ - 'remote', 'http://localhost:8001', 'query', - '--index-name', 'docs', '--json', - ]) + assert payload["tags"] == ["a", "b"] + + @patch( + "sys.argv", + [ + "remote", + "http://localhost:8001", + "query", + "--index-name", + "docs", + "--json", + ], + ) def test_remote_json_output(self) -> None: """--json flag should output raw JSON response.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { - 'results': [{'score': 0.9, 'content': 'test'}], + "results": [{"score": 0.9, "content": "test"}], } mock_requests = _make_mock_requests_module(post_return=mock_response) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print') as mock_print: + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print") as mock_print, + ): remote_command() - printed = ''.join(str(c.args[0]) for c in mock_print.call_args_list if c.args) + printed = "".join(str(c.args[0]) for c in mock_print.call_args_list if c.args) assert '"results"' in printed - @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) + @patch( + "sys.argv", ["remote", "http://localhost:8001", "query", "--index-name", "docs"] + ) def test_remote_success_with_results(self) -> None: """Successful response with results should print them.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { - 'results': [ + "results": [ { - 'score': 0.95, - 'content': 'Hello world', - 'metadata': {'filename': 'test.md', 'section': 'intro'}, + "score": 0.95, + "content": "Hello world", + "metadata": {"filename": "test.md", "section": "intro"}, } ], - 'enhanced_query': 'enhanced query', + "enhanced_query": "enhanced query", } mock_requests = _make_mock_requests_module(post_return=mock_response) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print') as mock_print: + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print") as mock_print, + ): remote_command() printed = [str(c) for c in mock_print.call_args_list] - assert any('Found 1 result' in s for s in printed) + assert any("Found 1 result" in s for s in printed) - @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) + @patch( + "sys.argv", ["remote", "http://localhost:8001", "query", "--index-name", "docs"] + ) def test_remote_no_results(self) -> None: """Empty results should print 'No results found'.""" mock_response = Mock() mock_response.status_code = 200 - mock_response.json.return_value = {'results': []} + mock_response.json.return_value = {"results": []} mock_requests = _make_mock_requests_module(post_return=mock_response) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit): + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit), + ): remote_command() mock_print.assert_any_call("No results found for 'query' in index 'docs'") - @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) + @patch( + "sys.argv", ["remote", "http://localhost:8001", "query", "--index-name", "docs"] + ) def test_remote_404_json_parse_error(self) -> None: """404 response with unparseable JSON should fallback.""" mock_response = Mock() @@ -1895,15 +2398,19 @@ def test_remote_404_json_parse_error(self) -> None: mock_requests = _make_mock_requests_module(post_return=mock_response) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): remote_command() assert exc_info.value.code == 1 mock_print.assert_any_call("Error: Index not found") - @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) + @patch( + "sys.argv", ["remote", "http://localhost:8001", "query", "--index-name", "docs"] + ) def test_remote_500_json_parse_error(self) -> None: """Non-404 error with unparseable JSON should fallback to status code.""" mock_response = Mock() @@ -1913,10 +2420,12 @@ def test_remote_500_json_parse_error(self) -> None: mock_requests = _make_mock_requests_module(post_return=mock_response) - with patch.dict('sys.modules', {'requests': mock_requests}), \ - patch('builtins.print') as mock_print, \ - pytest.raises(SystemExit) as exc_info: + with ( + patch.dict("sys.modules", {"requests": mock_requests}), + patch("builtins.print") as mock_print, + pytest.raises(SystemExit) as exc_info, + ): remote_command() assert exc_info.value.code == 1 - mock_print.assert_any_call("Error: HTTP 500: Internal Server Error") \ No newline at end of file + mock_print.assert_any_call("Error: HTTP 500: Internal Server Error") diff --git a/tests/unit/cli/test_dokku.py b/tests/unit/cli/test_dokku.py index 4b5c6412..e3d5f58c 100644 --- a/tests/unit/cli/test_dokku.py +++ b/tests/unit/cli/test_dokku.py @@ -23,9 +23,8 @@ import sys import json import argparse -from pathlib import Path, PurePosixPath -from unittest.mock import Mock, patch, MagicMock, call, mock_open -from io import StringIO +from pathlib import Path +from unittest.mock import patch, MagicMock, mock_open from signalwire.cli.dokku import ( Colors, @@ -69,41 +68,43 @@ # Colors Class Tests # ============================================================================= + class TestColors: """Tests for the ANSI color constants.""" def test_colors_has_red(self) -> None: - assert Colors.RED == '\033[0;31m' + assert Colors.RED == "\033[0;31m" def test_colors_has_green(self) -> None: - assert Colors.GREEN == '\033[0;32m' + assert Colors.GREEN == "\033[0;32m" def test_colors_has_yellow(self) -> None: - assert Colors.YELLOW == '\033[1;33m' + assert Colors.YELLOW == "\033[1;33m" def test_colors_has_blue(self) -> None: - assert Colors.BLUE == '\033[0;34m' + assert Colors.BLUE == "\033[0;34m" def test_colors_has_cyan(self) -> None: - assert Colors.CYAN == '\033[0;36m' + assert Colors.CYAN == "\033[0;36m" def test_colors_has_magenta(self) -> None: - assert Colors.MAGENTA == '\033[0;35m' + assert Colors.MAGENTA == "\033[0;35m" def test_colors_has_bold(self) -> None: - assert Colors.BOLD == '\033[1m' + assert Colors.BOLD == "\033[1m" def test_colors_has_dim(self) -> None: - assert Colors.DIM == '\033[2m' + assert Colors.DIM == "\033[2m" def test_colors_has_nc(self) -> None: - assert Colors.NC == '\033[0m' + assert Colors.NC == "\033[0m" # ============================================================================= # Print Function Tests # ============================================================================= + class TestPrintFunctions: """Tests for colored print utility functions.""" @@ -144,73 +145,78 @@ def test_print_header(self, capsys: pytest.CaptureFixture[str]) -> None: # Prompt Function Tests # ============================================================================= + class TestPrompt: """Tests for interactive prompt functions.""" - @patch('builtins.input', return_value='myvalue') + @patch("builtins.input", return_value="myvalue") def test_prompt_returns_user_input(self, mock_input: MagicMock) -> None: result = prompt("Enter name") - assert result == 'myvalue' + assert result == "myvalue" mock_input.assert_called_once_with("Enter name: ") - @patch('builtins.input', return_value='') + @patch("builtins.input", return_value="") def test_prompt_returns_default_on_empty(self, mock_input: MagicMock) -> None: result = prompt("Enter name", "default-val") - assert result == 'default-val' + assert result == "default-val" mock_input.assert_called_once_with("Enter name [default-val]: ") - @patch('builtins.input', return_value='custom') - def test_prompt_returns_user_input_over_default(self, mock_input: MagicMock) -> None: + @patch("builtins.input", return_value="custom") + def test_prompt_returns_user_input_over_default( + self, mock_input: MagicMock + ) -> None: result = prompt("Enter name", "default-val") - assert result == 'custom' + assert result == "custom" - @patch('builtins.input', return_value=' spaced ') + @patch("builtins.input", return_value=" spaced ") def test_prompt_strips_whitespace(self, mock_input: MagicMock) -> None: result = prompt("Enter name") - assert result == 'spaced' + assert result == "spaced" - @patch('builtins.input', return_value=' ') - def test_prompt_empty_after_strip_returns_default(self, mock_input: MagicMock) -> None: + @patch("builtins.input", return_value=" ") + def test_prompt_empty_after_strip_returns_default( + self, mock_input: MagicMock + ) -> None: result = prompt("Question", "fallback") - assert result == 'fallback' + assert result == "fallback" class TestPromptYesNo: """Tests for the yes/no prompt function.""" - @patch('builtins.input', return_value='') + @patch("builtins.input", return_value="") def test_default_true_on_empty(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=True) assert result is True assert "Y/n" in mock_input.call_args[0][0] - @patch('builtins.input', return_value='') + @patch("builtins.input", return_value="") def test_default_false_on_empty(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=False) assert result is False assert "y/N" in mock_input.call_args[0][0] - @patch('builtins.input', return_value='y') + @patch("builtins.input", return_value="y") def test_accepts_y(self, mock_input: MagicMock) -> None: assert prompt_yes_no("OK?", default=False) is True - @patch('builtins.input', return_value='yes') + @patch("builtins.input", return_value="yes") def test_accepts_yes(self, mock_input: MagicMock) -> None: assert prompt_yes_no("OK?", default=False) is True - @patch('builtins.input', return_value='Y') + @patch("builtins.input", return_value="Y") def test_accepts_uppercase_y(self, mock_input: MagicMock) -> None: assert prompt_yes_no("OK?", default=False) is True - @patch('builtins.input', return_value='n') + @patch("builtins.input", return_value="n") def test_rejects_n(self, mock_input: MagicMock) -> None: assert prompt_yes_no("OK?", default=True) is False - @patch('builtins.input', return_value='no') + @patch("builtins.input", return_value="no") def test_rejects_no(self, mock_input: MagicMock) -> None: assert prompt_yes_no("OK?", default=True) is False - @patch('builtins.input', return_value='maybe') + @patch("builtins.input", return_value="maybe") def test_non_yes_returns_false(self, mock_input: MagicMock) -> None: assert prompt_yes_no("OK?", default=True) is False @@ -219,6 +225,7 @@ def test_non_yes_returns_false(self, mock_input: MagicMock) -> None: # Password Generation Tests # ============================================================================= + class TestGeneratePassword: """Tests for the password generation function.""" @@ -239,13 +246,14 @@ def test_contains_only_url_safe_chars(self) -> None: pw = generate_password(64) # token_urlsafe uses A-Z, a-z, 0-9, -, _ for ch in pw: - assert ch.isalnum() or ch in ('-', '_'), f"Unexpected char: {ch}" + assert ch.isalnum() or ch in ("-", "_"), f"Unexpected char: {ch}" # ============================================================================= # DokkuProjectGenerator Tests # ============================================================================= + class TestDokkuProjectGeneratorInit: """Tests for DokkuProjectGenerator initialization and name derivation.""" @@ -274,46 +282,72 @@ def test_default_project_dir(self) -> None: gen = DokkuProjectGenerator("myapp", {}) assert gen.project_dir == Path("./myapp") - def test_custom_project_dir(self) -> None: - gen = DokkuProjectGenerator("myapp", {'project_dir': '/tmp/custom'}) - assert str(gen.project_dir) == "/tmp/custom" + def test_custom_project_dir(self, tmp_path: Path) -> None: + # Compare Path objects, not str() against a POSIX literal: `str(Path)` + # renders with the platform separator, so a hardcoded "/tmp/custom" can + # never match on Windows (it yields "\tmp\custom"). Using `tmp_path` + # also keeps the test off a hardcoded /tmp. + custom = tmp_path / "custom" + gen = DokkuProjectGenerator("myapp", {"project_dir": str(custom)}) + assert gen.project_dir == custom class TestDokkuProjectGeneratorGenerate: """Tests for the generate() method and file writing.""" - @patch.object(DokkuProjectGenerator, '_write_cicd_files') - @patch.object(DokkuProjectGenerator, '_write_simple_files') - @patch.object(DokkuProjectGenerator, '_write_core_files') - @patch('signalwire.cli.dokku.print_success') - def test_generate_simple_mode(self, mock_ps: MagicMock, mock_core: MagicMock, mock_simple: MagicMock, mock_cicd: MagicMock, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("testapp", {'project_dir': str(tmp_path / 'out')}) + @patch.object(DokkuProjectGenerator, "_write_cicd_files") + @patch.object(DokkuProjectGenerator, "_write_simple_files") + @patch.object(DokkuProjectGenerator, "_write_core_files") + @patch("signalwire.cli.dokku.print_success") + def test_generate_simple_mode( + self, + mock_ps: MagicMock, + mock_core: MagicMock, + mock_simple: MagicMock, + mock_cicd: MagicMock, + tmp_path: Path, + ) -> None: + gen = DokkuProjectGenerator("testapp", {"project_dir": str(tmp_path / "out")}) result = gen.generate() assert result is True mock_core.assert_called_once() mock_simple.assert_called_once() mock_cicd.assert_not_called() - @patch.object(DokkuProjectGenerator, '_write_cicd_files') - @patch.object(DokkuProjectGenerator, '_write_simple_files') - @patch.object(DokkuProjectGenerator, '_write_core_files') - @patch('signalwire.cli.dokku.print_success') - def test_generate_cicd_mode(self, mock_ps: MagicMock, mock_core: MagicMock, mock_simple: MagicMock, mock_cicd: MagicMock, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("testapp", { - 'project_dir': str(tmp_path / 'out'), - 'cicd': True - }) + @patch.object(DokkuProjectGenerator, "_write_cicd_files") + @patch.object(DokkuProjectGenerator, "_write_simple_files") + @patch.object(DokkuProjectGenerator, "_write_core_files") + @patch("signalwire.cli.dokku.print_success") + def test_generate_cicd_mode( + self, + mock_ps: MagicMock, + mock_core: MagicMock, + mock_simple: MagicMock, + mock_cicd: MagicMock, + tmp_path: Path, + ) -> None: + gen = DokkuProjectGenerator( + "testapp", {"project_dir": str(tmp_path / "out"), "cicd": True} + ) result = gen.generate() assert result is True mock_core.assert_called_once() mock_cicd.assert_called_once() mock_simple.assert_not_called() - @patch.object(DokkuProjectGenerator, '_write_core_files', side_effect=OSError("disk full")) - @patch('signalwire.cli.dokku.print_error') - @patch('signalwire.cli.dokku.print_success') - def test_generate_handles_exception(self, mock_ps: MagicMock, mock_pe: MagicMock, mock_core: MagicMock, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("testapp", {'project_dir': str(tmp_path / 'out')}) + @patch.object( + DokkuProjectGenerator, "_write_core_files", side_effect=OSError("disk full") + ) + @patch("signalwire.cli.dokku.print_error") + @patch("signalwire.cli.dokku.print_success") + def test_generate_handles_exception( + self, + mock_ps: MagicMock, + mock_pe: MagicMock, + mock_core: MagicMock, + tmp_path: Path, + ) -> None: + gen = DokkuProjectGenerator("testapp", {"project_dir": str(tmp_path / "out")}) result = gen.generate() assert result is False mock_pe.assert_called_once() @@ -324,83 +358,110 @@ class TestDokkuProjectGeneratorWriteFile: """Tests for the _write_file helper.""" def test_write_file_creates_file(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("testapp", {'project_dir': str(tmp_path)}) - gen._write_file('hello.txt', 'Hello World') - assert (tmp_path / 'hello.txt').exists() - assert (tmp_path / 'hello.txt').read_text() == 'Hello World' + gen = DokkuProjectGenerator("testapp", {"project_dir": str(tmp_path)}) + gen._write_file("hello.txt", "Hello World") + assert (tmp_path / "hello.txt").exists() + assert (tmp_path / "hello.txt").read_text(encoding="utf-8") == "Hello World" def test_write_file_creates_nested_dirs(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("testapp", {'project_dir': str(tmp_path)}) - gen._write_file('a/b/c.txt', 'nested') - assert (tmp_path / 'a' / 'b' / 'c.txt').exists() - + gen = DokkuProjectGenerator("testapp", {"project_dir": str(tmp_path)}) + gen._write_file("a/b/c.txt", "nested") + assert (tmp_path / "a" / "b" / "c.txt").exists() + + @pytest.mark.skipif( + sys.platform == "win32", + reason="POSIX permission bits: Windows has no execute bit, so st_mode never " + "carries 0o755 (it reports 0o666/0o444 from the read-only attribute). " + "The Windows-side behaviour is covered by " + "test_write_file_executable_requests_chmod below.", + ) def test_write_file_executable(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("testapp", {'project_dir': str(tmp_path)}) - gen._write_file('script.sh', '#!/bin/bash', executable=True) - mode = (tmp_path / 'script.sh').stat().st_mode + gen = DokkuProjectGenerator("testapp", {"project_dir": str(tmp_path)}) + gen._write_file("script.sh", "#!/bin/bash", executable=True) + mode = (tmp_path / "script.sh").stat().st_mode assert mode & 0o755 == 0o755 + def test_write_file_executable_requests_chmod(self, tmp_path: Path) -> None: + """`executable=True` must chmod 0o755 -- assertable on every platform. + + Windows drops the POSIX bits, so the observable-mode assertion above cannot + run there. Asserting the *request* keeps the contract covered on Windows and + catches a regression that silently stopped chmod-ing. + """ + gen = DokkuProjectGenerator("testapp", {"project_dir": str(tmp_path)}) + with patch.object(Path, "chmod", autospec=True) as mock_chmod: + gen._write_file("script.sh", "#!/bin/bash", executable=True) + assert (tmp_path / "script.sh").exists() + mock_chmod.assert_called_once() + assert mock_chmod.call_args[0][1] == 0o755 + + def test_write_file_not_executable_does_not_chmod(self, tmp_path: Path) -> None: + """The default path must not chmod at all (guards the flag's meaning).""" + gen = DokkuProjectGenerator("testapp", {"project_dir": str(tmp_path)}) + with patch.object(Path, "chmod", autospec=True) as mock_chmod: + gen._write_file("plain.txt", "data") + mock_chmod.assert_not_called() + class TestDokkuProjectGeneratorCoreFIles: """Tests that _write_core_files creates all expected files.""" def test_core_files_without_web(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) + gen = DokkuProjectGenerator("myapp", {"project_dir": str(tmp_path)}) gen._write_core_files() - assert (tmp_path / 'Procfile').exists() - assert (tmp_path / 'runtime.txt').exists() - assert (tmp_path / 'requirements.txt').exists() - assert (tmp_path / 'CHECKS').exists() - assert (tmp_path / '.gitignore').exists() - assert (tmp_path / '.env.example').exists() - assert (tmp_path / 'app.json').exists() - assert (tmp_path / 'app.py').exists() + assert (tmp_path / "Procfile").exists() + assert (tmp_path / "runtime.txt").exists() + assert (tmp_path / "requirements.txt").exists() + assert (tmp_path / "CHECKS").exists() + assert (tmp_path / ".gitignore").exists() + assert (tmp_path / ".env.example").exists() + assert (tmp_path / "app.json").exists() + assert (tmp_path / "app.py").exists() # Standard template used (not web) - content = (tmp_path / 'app.py').read_text() - assert 'AgentBase' in content - assert 'AgentServer' not in content + content = (tmp_path / "app.py").read_text(encoding="utf-8") + assert "AgentBase" in content + assert "AgentServer" not in content def test_core_files_with_web(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", { - 'project_dir': str(tmp_path), - 'web': True - }) + gen = DokkuProjectGenerator( + "myapp", {"project_dir": str(tmp_path), "web": True} + ) gen._write_core_files() - content = (tmp_path / 'app.py').read_text() - assert 'AgentServer' in content - assert (tmp_path / 'web' / 'index.html').exists() + content = (tmp_path / "app.py").read_text(encoding="utf-8") + assert "AgentServer" in content + assert (tmp_path / "web" / "index.html").exists() def test_procfile_content(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) + gen = DokkuProjectGenerator("myapp", {"project_dir": str(tmp_path)}) gen._write_core_files() - content = (tmp_path / 'Procfile').read_text() - assert 'gunicorn' in content - assert 'uvicorn' in content + content = (tmp_path / "Procfile").read_text(encoding="utf-8") + assert "gunicorn" in content + assert "uvicorn" in content def test_runtime_content(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) + gen = DokkuProjectGenerator("myapp", {"project_dir": str(tmp_path)}) gen._write_core_files() - content = (tmp_path / 'runtime.txt').read_text() - assert 'python-3.11' in content + content = (tmp_path / "runtime.txt").read_text(encoding="utf-8") + assert "python-3.11" in content def test_env_example_contains_app_name(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("my-cool-app", {'project_dir': str(tmp_path)}) + gen = DokkuProjectGenerator("my-cool-app", {"project_dir": str(tmp_path)}) gen._write_core_files() - content = (tmp_path / '.env.example').read_text() - assert 'my-cool-app' in content + content = (tmp_path / ".env.example").read_text(encoding="utf-8") + assert "my-cool-app" in content def test_app_json_contains_app_name(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("testbot", {'project_dir': str(tmp_path)}) + gen = DokkuProjectGenerator("testbot", {"project_dir": str(tmp_path)}) gen._write_core_files() - content = (tmp_path / 'app.json').read_text() + content = (tmp_path / "app.json").read_text(encoding="utf-8") data = json.loads(content) - assert data['name'] == 'testbot' + assert data["name"] == "testbot" def test_app_py_uses_correct_class_name(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("my-agent", {'project_dir': str(tmp_path)}) + gen = DokkuProjectGenerator("my-agent", {"project_dir": str(tmp_path)}) gen._write_core_files() - content = (tmp_path / 'app.py').read_text() - assert 'class MyAgentAgent' in content + content = (tmp_path / "app.py").read_text(encoding="utf-8") + assert "class MyAgentAgent" in content assert 'name="my-agent"' in content @@ -408,186 +469,349 @@ class TestDokkuProjectGeneratorSimpleFiles: """Tests for _write_simple_files.""" def test_simple_files_created(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", { - 'project_dir': str(tmp_path), - 'dokku_host': 'dokku.example.com', - 'route': 'swaig' - }) + gen = DokkuProjectGenerator( + "myapp", + { + "project_dir": str(tmp_path), + "dokku_host": "dokku.example.com", + "route": "swaig", + }, + ) gen._write_simple_files() - assert (tmp_path / 'deploy.sh').exists() - assert (tmp_path / 'README.md').exists() - + assert (tmp_path / "deploy.sh").exists() + assert (tmp_path / "README.md").exists() + + @pytest.mark.skipif( + sys.platform == "win32", + reason="POSIX permission bits: Windows has no execute bit, so st_mode never " + "carries 0o755. Windows-side coverage is " + "test_deploy_script_requests_executable_mode below.", + ) def test_deploy_script_is_executable(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", { - 'project_dir': str(tmp_path), - 'dokku_host': 'dokku.example.com', - 'route': 'swaig' - }) + gen = DokkuProjectGenerator( + "myapp", + { + "project_dir": str(tmp_path), + "dokku_host": "dokku.example.com", + "route": "swaig", + }, + ) gen._write_simple_files() - mode = (tmp_path / 'deploy.sh').stat().st_mode + mode = (tmp_path / "deploy.sh").stat().st_mode assert mode & 0o755 == 0o755 + def test_deploy_script_requests_executable_mode(self, tmp_path: Path) -> None: + """deploy.sh must be written with executable=True on every platform.""" + gen = DokkuProjectGenerator( + "myapp", + { + "project_dir": str(tmp_path), + "dokku_host": "dokku.example.com", + "route": "swaig", + }, + ) + with patch.object(Path, "chmod", autospec=True) as mock_chmod: + gen._write_simple_files() + chmodded = {Path(c[0][0]).name: c[0][1] for c in mock_chmod.call_args_list} + assert chmodded == {"deploy.sh": 0o755}, ( + "deploy.sh (and only it) must be made executable" + ) + def test_deploy_script_contains_host(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", { - 'project_dir': str(tmp_path), - 'dokku_host': 'dokku.myhost.com', - 'route': 'swaig' - }) + gen = DokkuProjectGenerator( + "myapp", + { + "project_dir": str(tmp_path), + "dokku_host": "dokku.myhost.com", + "route": "swaig", + }, + ) gen._write_simple_files() - content = (tmp_path / 'deploy.sh').read_text() - assert 'dokku.myhost.com' in content + content = (tmp_path / "deploy.sh").read_text(encoding="utf-8") + assert "dokku.myhost.com" in content def test_readme_contains_app_name(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", { - 'project_dir': str(tmp_path), - 'dokku_host': 'dokku.example.com', - 'route': 'swaig' - }) + gen = DokkuProjectGenerator( + "myapp", + { + "project_dir": str(tmp_path), + "dokku_host": "dokku.example.com", + "route": "swaig", + }, + ) gen._write_simple_files() - content = (tmp_path / 'README.md').read_text() - assert 'myapp' in content + content = (tmp_path / "README.md").read_text(encoding="utf-8") + assert "myapp" in content def test_default_dokku_host(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", { - 'project_dir': str(tmp_path), - }) + gen = DokkuProjectGenerator( + "myapp", + { + "project_dir": str(tmp_path), + }, + ) gen._write_simple_files() - content = (tmp_path / 'deploy.sh').read_text() - assert 'dokku.yourdomain.com' in content + content = (tmp_path / "deploy.sh").read_text(encoding="utf-8") + assert "dokku.yourdomain.com" in content + + +class TestGeneratedFilesAreUtf8: + """Generated files must be written as UTF-8 regardless of platform locale. + + Several templates embed box-drawing characters (U+2500 '-', U+2550 '=') and + arrows (U+2192). `Path.write_text()` without an explicit `encoding` uses the + platform default, which on Windows is cp1252 -- it cannot represent those + code points and raises `UnicodeEncodeError: 'charmap' codec can't encode + characters in position ...` (nightly Multi-OS run 30238061313, windows-latest; + 8 direct failures plus 4 more surfacing as `generate()` returning False). + + These assertions are platform-independent: they check the bytes on disk are + valid UTF-8 and round-trip to the original text, which is what an explicit + `encoding="utf-8"` guarantees and what the platform default does not. + """ + + def _non_ascii(self, text: str) -> set[str]: + return {c for c in text if ord(c) > 127} + + def test_deploy_script_round_trips_as_utf8(self, tmp_path: Path) -> None: + gen = DokkuProjectGenerator( + "myapp", + { + "project_dir": str(tmp_path), + "dokku_host": "dokku.example.com", + "route": "swaig", + }, + ) + gen._write_simple_files() + + script = tmp_path / "deploy.sh" + # Decodes as UTF-8 -- raises if the file was written in cp1252/latin-1. + text = script.read_bytes().decode("utf-8") + + # Compare against the source template, NOT against a second read of the + # file: text-mode writes translate "\n" -> "\r\n" on Windows and + # `read_text` translates it back (universal newlines), so + # `read_bytes().decode()` and `read_text()` legitimately differ there. + # Line endings are not what this test is about -- the encoding of the + # non-ASCII characters is. (Comparing those two reads is what made this + # test fail on the Windows runner while the encoding fix itself was fine.) + expected = DEPLOY_SCRIPT_TEMPLATE.format( + app_name="myapp", dokku_host="dokku.example.com", route="swaig" + ) + assert text.splitlines() == expected.splitlines() + + # The characters that break the Windows default codec must survive + # byte-for-byte -- this is the actual regression being guarded. + assert self._non_ascii(text) == self._non_ascii(expected) + # Guard the premise: if a template edit ever removed these, the test + # would still pass but stop proving anything. + assert self._non_ascii(text), "expected non-ASCII content in deploy.sh" + + def test_generated_content_is_not_cp1252_encodable(self, tmp_path: Path) -> None: + """The regression premise: this content genuinely cannot be cp1252. + + Without this, an `encoding="utf-8"` fix could silently become untested if + the templates were ever reduced to pure ASCII. + """ + gen = DokkuProjectGenerator( + "myapp", + { + "project_dir": str(tmp_path), + "dokku_host": "dokku.example.com", + "route": "swaig", + }, + ) + gen._write_simple_files() + gen._write_cicd_files() + + offenders = [] + for path in sorted(tmp_path.rglob("*")): + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8") # must not raise + try: + text.encode("cp1252") + except UnicodeEncodeError: + offenders.append(path.name) + + assert offenders, ( + "no generated file contains cp1252-hostile characters -- the UTF-8 " + "regression this guards is no longer reachable; re-check the templates" + ) + + def test_all_generated_files_decode_as_utf8(self, tmp_path: Path) -> None: + """Every file a full generate() produces must be valid UTF-8.""" + gen = DokkuProjectGenerator( + "myapp", + { + "project_dir": str(tmp_path / "proj"), + "dokku_host": "dokku.example.com", + "route": "swaig", + "cicd": True, + "web": True, + }, + ) + assert gen.generate() is True + + checked = 0 + for path in sorted((tmp_path / "proj").rglob("*")): + if path.is_file(): + path.read_bytes().decode("utf-8") # raises on a mis-encoded write + checked += 1 + assert checked > 0, "generate() produced no files to check" class TestDokkuProjectGeneratorCicdFiles: """Tests for _write_cicd_files.""" def test_cicd_files_created(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) + gen = DokkuProjectGenerator("myapp", {"project_dir": str(tmp_path)}) gen._write_cicd_files() - assert (tmp_path / '.github' / 'workflows' / 'deploy.yml').exists() - assert (tmp_path / '.github' / 'workflows' / 'preview.yml').exists() - assert (tmp_path / '.dokku' / 'config.yml').exists() - assert (tmp_path / '.dokku' / 'services.yml').exists() - assert (tmp_path / 'README.md').exists() + assert (tmp_path / ".github" / "workflows" / "deploy.yml").exists() + assert (tmp_path / ".github" / "workflows" / "preview.yml").exists() + assert (tmp_path / ".dokku" / "config.yml").exists() + assert (tmp_path / ".dokku" / "services.yml").exists() + assert (tmp_path / "README.md").exists() def test_deploy_workflow_content(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) + gen = DokkuProjectGenerator("myapp", {"project_dir": str(tmp_path)}) gen._write_cicd_files() - content = (tmp_path / '.github' / 'workflows' / 'deploy.yml').read_text() - assert 'Deploy' in content - assert 'dokku-deploy-system' in content + content = (tmp_path / ".github" / "workflows" / "deploy.yml").read_text( + encoding="utf-8" + ) + assert "Deploy" in content + assert "dokku-deploy-system" in content def test_preview_workflow_content(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) + gen = DokkuProjectGenerator("myapp", {"project_dir": str(tmp_path)}) gen._write_cicd_files() - content = (tmp_path / '.github' / 'workflows' / 'preview.yml').read_text() - assert 'Preview' in content - assert 'pull_request' in content + content = (tmp_path / ".github" / "workflows" / "preview.yml").read_text( + encoding="utf-8" + ) + assert "Preview" in content + assert "pull_request" in content def test_config_yml_content(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) + gen = DokkuProjectGenerator("myapp", {"project_dir": str(tmp_path)}) gen._write_cicd_files() - content = (tmp_path / '.dokku' / 'config.yml').read_text() - assert 'resources:' in content - assert 'healthcheck:' in content + content = (tmp_path / ".dokku" / "config.yml").read_text(encoding="utf-8") + assert "resources:" in content + assert "healthcheck:" in content def test_services_yml_content(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) + gen = DokkuProjectGenerator("myapp", {"project_dir": str(tmp_path)}) gen._write_cicd_files() - content = (tmp_path / '.dokku' / 'services.yml').read_text() - assert 'postgres:' in content - assert 'redis:' in content + content = (tmp_path / ".dokku" / "services.yml").read_text(encoding="utf-8") + assert "postgres:" in content + assert "redis:" in content def test_cicd_readme_contains_app_name(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("superbot", {'project_dir': str(tmp_path)}) + gen = DokkuProjectGenerator("superbot", {"project_dir": str(tmp_path)}) gen._write_cicd_files() - content = (tmp_path / 'README.md').read_text() - assert 'superbot' in content + content = (tmp_path / "README.md").read_text(encoding="utf-8") + assert "superbot" in content class TestDokkuProjectGeneratorWebFiles: """Tests for _write_web_files.""" def test_web_dir_created(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("myapp", { - 'project_dir': str(tmp_path), - 'route': 'swaig' - }) + gen = DokkuProjectGenerator( + "myapp", {"project_dir": str(tmp_path), "route": "swaig"} + ) gen._write_web_files() - assert (tmp_path / 'web').is_dir() - assert (tmp_path / 'web' / 'index.html').exists() + assert (tmp_path / "web").is_dir() + assert (tmp_path / "web" / "index.html").exists() def test_web_index_html_contains_agent_name(self, tmp_path: Path) -> None: - gen = DokkuProjectGenerator("Cool Bot", { - 'project_dir': str(tmp_path), - 'route': 'swaig' - }) + gen = DokkuProjectGenerator( + "Cool Bot", {"project_dir": str(tmp_path), "route": "swaig"} + ) gen._write_web_files() - content = (tmp_path / 'web' / 'index.html').read_text() - assert 'Cool Bot' in content + content = (tmp_path / "web" / "index.html").read_text(encoding="utf-8") + assert "Cool Bot" in content # ============================================================================= # Full Generate Integration Tests (using tmp_path) # ============================================================================= + class TestDokkuProjectGeneratorFullGenerate: """Integration-level tests for full project generation with tmp_path.""" def test_full_simple_generate(self, tmp_path: Path) -> None: out = tmp_path / "proj" - gen = DokkuProjectGenerator("test-agent", { - 'project_dir': str(out), - 'dokku_host': 'dokku.test.com', - 'route': 'swaig', - }) + gen = DokkuProjectGenerator( + "test-agent", + { + "project_dir": str(out), + "dokku_host": "dokku.test.com", + "route": "swaig", + }, + ) result = gen.generate() assert result is True # Core files - assert (out / 'Procfile').exists() - assert (out / 'app.py').exists() - assert (out / 'requirements.txt').exists() + assert (out / "Procfile").exists() + assert (out / "app.py").exists() + assert (out / "requirements.txt").exists() # Simple files - assert (out / 'deploy.sh').exists() - assert (out / 'README.md').exists() + assert (out / "deploy.sh").exists() + assert (out / "README.md").exists() # No cicd files - assert not (out / '.github').exists() - assert not (out / '.dokku').exists() + assert not (out / ".github").exists() + assert not (out / ".dokku").exists() def test_full_cicd_generate(self, tmp_path: Path) -> None: out = tmp_path / "proj" - gen = DokkuProjectGenerator("test-agent", { - 'project_dir': str(out), - 'cicd': True, - }) + gen = DokkuProjectGenerator( + "test-agent", + { + "project_dir": str(out), + "cicd": True, + }, + ) result = gen.generate() assert result is True - assert (out / '.github' / 'workflows' / 'deploy.yml').exists() - assert (out / '.dokku' / 'config.yml').exists() + assert (out / ".github" / "workflows" / "deploy.yml").exists() + assert (out / ".dokku" / "config.yml").exists() # No simple deploy.sh - assert not (out / 'deploy.sh').exists() + assert not (out / "deploy.sh").exists() def test_full_generate_with_web(self, tmp_path: Path) -> None: out = tmp_path / "proj" - gen = DokkuProjectGenerator("web-agent", { - 'project_dir': str(out), - 'web': True, - }) + gen = DokkuProjectGenerator( + "web-agent", + { + "project_dir": str(out), + "web": True, + }, + ) result = gen.generate() assert result is True - assert (out / 'web' / 'index.html').exists() - content = (out / 'app.py').read_text() - assert 'AgentServer' in content + assert (out / "web" / "index.html").exists() + content = (out / "app.py").read_text(encoding="utf-8") + assert "AgentServer" in content # ============================================================================= # cmd_init Tests # ============================================================================= + class TestCmdInit: """Tests for the cmd_init CLI command handler.""" - def _make_args(self, name: str = 'testapp', cicd: bool = False, web: bool = False, - host: str | None = None, dir_val: str | None = None, - force: bool = False) -> argparse.Namespace: + def _make_args( + self, + name: str = "testapp", + cicd: bool = False, + web: bool = False, + host: str | None = None, + dir_val: str | None = None, + force: bool = False, + ) -> argparse.Namespace: args = argparse.Namespace() args.name = name args.cicd = cicd @@ -597,70 +821,85 @@ def _make_args(self, name: str = 'testapp', cicd: bool = False, web: bool = Fals args.force = force return args - @patch.object(DokkuProjectGenerator, 'generate', return_value=True) - @patch('signalwire.cli.dokku.Path') - def test_init_simple_with_host(self, mock_path_cls: MagicMock, mock_gen: MagicMock) -> None: + @patch.object(DokkuProjectGenerator, "generate", return_value=True) + @patch("signalwire.cli.dokku.Path") + def test_init_simple_with_host( + self, mock_path_cls: MagicMock, mock_gen: MagicMock + ) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance - args = self._make_args(host='dokku.example.com') + args = self._make_args(host="dokku.example.com") result = cmd_init(args) assert result == 0 mock_gen.assert_called_once() - @patch.object(DokkuProjectGenerator, 'generate', return_value=True) - @patch('signalwire.cli.dokku.Path') - def test_init_cicd_mode(self, mock_path_cls: MagicMock, mock_gen: MagicMock) -> None: + @patch.object(DokkuProjectGenerator, "generate", return_value=True) + @patch("signalwire.cli.dokku.Path") + def test_init_cicd_mode( + self, mock_path_cls: MagicMock, mock_gen: MagicMock + ) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance - args = self._make_args(cicd=True, host='dokku.example.com') + args = self._make_args(cicd=True, host="dokku.example.com") result = cmd_init(args) assert result == 0 - @patch.object(DokkuProjectGenerator, 'generate', return_value=False) - @patch('signalwire.cli.dokku.Path') - def test_init_generation_failure_returns_1(self, mock_path_cls: MagicMock, mock_gen: MagicMock) -> None: + @patch.object(DokkuProjectGenerator, "generate", return_value=False) + @patch("signalwire.cli.dokku.Path") + def test_init_generation_failure_returns_1( + self, mock_path_cls: MagicMock, mock_gen: MagicMock + ) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance - args = self._make_args(host='dokku.example.com') + args = self._make_args(host="dokku.example.com") result = cmd_init(args) assert result == 1 - @patch('signalwire.cli.dokku.shutil') - @patch.object(DokkuProjectGenerator, 'generate', return_value=True) - @patch('signalwire.cli.dokku.Path') - def test_init_force_overwrites_existing_dir(self, mock_path_cls: MagicMock, mock_gen: MagicMock, mock_shutil: MagicMock) -> None: + @patch("signalwire.cli.dokku.shutil") + @patch.object(DokkuProjectGenerator, "generate", return_value=True) + @patch("signalwire.cli.dokku.Path") + def test_init_force_overwrites_existing_dir( + self, mock_path_cls: MagicMock, mock_gen: MagicMock, mock_shutil: MagicMock + ) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = True mock_path_cls.return_value = mock_path_instance - args = self._make_args(host='dokku.example.com', force=True) + args = self._make_args(host="dokku.example.com", force=True) result = cmd_init(args) assert result == 0 mock_shutil.rmtree.assert_called_once_with(mock_path_instance) - @patch('signalwire.cli.dokku.prompt_yes_no', return_value=False) - @patch('signalwire.cli.dokku.Path') - def test_init_existing_dir_no_force_aborts(self, mock_path_cls: MagicMock, mock_prompt: MagicMock) -> None: + @patch("signalwire.cli.dokku.prompt_yes_no", return_value=False) + @patch("signalwire.cli.dokku.Path") + def test_init_existing_dir_no_force_aborts( + self, mock_path_cls: MagicMock, mock_prompt: MagicMock + ) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = True mock_path_cls.return_value = mock_path_instance - args = self._make_args(host='dokku.example.com') + args = self._make_args(host="dokku.example.com") result = cmd_init(args) assert result == 1 - @patch('signalwire.cli.dokku.prompt_yes_no', side_effect=[False, True]) - @patch('signalwire.cli.dokku.prompt', return_value='dokku.example.com') - @patch.object(DokkuProjectGenerator, 'generate', return_value=True) - @patch('signalwire.cli.dokku.Path') - def test_init_interactive_mode_simple(self, mock_path_cls: MagicMock, mock_gen: MagicMock, - mock_prompt: MagicMock, mock_yes_no: MagicMock) -> None: + @patch("signalwire.cli.dokku.prompt_yes_no", side_effect=[False, True]) + @patch("signalwire.cli.dokku.prompt", return_value="dokku.example.com") + @patch.object(DokkuProjectGenerator, "generate", return_value=True) + @patch("signalwire.cli.dokku.Path") + def test_init_interactive_mode_simple( + self, + mock_path_cls: MagicMock, + mock_gen: MagicMock, + mock_prompt: MagicMock, + mock_yes_no: MagicMock, + ) -> None: """When no --host and no --cicd, enters interactive mode.""" mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False @@ -672,10 +911,12 @@ def test_init_interactive_mode_simple(self, mock_path_cls: MagicMock, mock_gen: # Should have prompted for cicd (False) and then web assert mock_yes_no.call_count == 2 - @patch('signalwire.cli.dokku.prompt_yes_no', side_effect=[True, True]) - @patch.object(DokkuProjectGenerator, 'generate', return_value=True) - @patch('signalwire.cli.dokku.Path') - def test_init_interactive_cicd_mode(self, mock_path_cls: MagicMock, mock_gen: MagicMock, mock_yes_no: MagicMock) -> None: + @patch("signalwire.cli.dokku.prompt_yes_no", side_effect=[True, True]) + @patch.object(DokkuProjectGenerator, "generate", return_value=True) + @patch("signalwire.cli.dokku.Path") + def test_init_interactive_cicd_mode( + self, mock_path_cls: MagicMock, mock_gen: MagicMock, mock_yes_no: MagicMock + ) -> None: """When user chooses cicd in interactive mode.""" mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False @@ -685,25 +926,29 @@ def test_init_interactive_cicd_mode(self, mock_path_cls: MagicMock, mock_gen: Ma result = cmd_init(args) assert result == 0 - @patch.object(DokkuProjectGenerator, 'generate', return_value=True) - @patch('signalwire.cli.dokku.Path') - def test_init_with_web_flag(self, mock_path_cls: MagicMock, mock_gen: MagicMock) -> None: + @patch.object(DokkuProjectGenerator, "generate", return_value=True) + @patch("signalwire.cli.dokku.Path") + def test_init_with_web_flag( + self, mock_path_cls: MagicMock, mock_gen: MagicMock + ) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance - args = self._make_args(host='dokku.example.com', web=True) + args = self._make_args(host="dokku.example.com", web=True) result = cmd_init(args) assert result == 0 - @patch.object(DokkuProjectGenerator, 'generate', return_value=True) - @patch('signalwire.cli.dokku.Path') - def test_init_custom_dir(self, mock_path_cls: MagicMock, mock_gen: MagicMock) -> None: + @patch.object(DokkuProjectGenerator, "generate", return_value=True) + @patch("signalwire.cli.dokku.Path") + def test_init_custom_dir( + self, mock_path_cls: MagicMock, mock_gen: MagicMock + ) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance - args = self._make_args(host='dokku.example.com', dir_val='/tmp/custom') + args = self._make_args(host="dokku.example.com", dir_val="build/custom") result = cmd_init(args) assert result == 0 @@ -712,16 +957,19 @@ def test_init_custom_dir(self, mock_path_cls: MagicMock, mock_gen: MagicMock) -> # cmd_deploy Tests # ============================================================================= + class TestCmdDeploy: """Tests for the cmd_deploy CLI command handler.""" - def _make_args(self, app: str | None = None, host: str | None = None) -> argparse.Namespace: + def _make_args( + self, app: str | None = None, host: str | None = None + ) -> argparse.Namespace: args = argparse.Namespace() args.app = app args.host = host return args - @patch('signalwire.cli.dokku.Path') + @patch("signalwire.cli.dokku.Path") def test_deploy_no_procfile_returns_error(self, mock_path_cls: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False @@ -731,9 +979,11 @@ def test_deploy_no_procfile_returns_error(self, mock_path_cls: MagicMock) -> Non result = cmd_deploy(args) assert result == 1 - @patch('signalwire.cli.dokku.subprocess') - @patch('signalwire.cli.dokku.Path') - def test_deploy_with_app_name_and_host(self, mock_path_cls: MagicMock, mock_subprocess: MagicMock) -> None: + @patch("signalwire.cli.dokku.subprocess") + @patch("signalwire.cli.dokku.Path") + def test_deploy_with_app_name_and_host( + self, mock_path_cls: MagicMock, mock_subprocess: MagicMock + ) -> None: # Procfile exists, .git exists path_instances = {} @@ -742,11 +992,11 @@ def path_side_effect(p: str) -> MagicMock: m = MagicMock() path_instances[p] = m # Procfile exists - if p == 'Procfile': + if p == "Procfile": m.exists.return_value = True - elif p == 'app.json': + elif p == "app.json": m.exists.return_value = False - elif p == '.git': + elif p == ".git": m.exists.return_value = True else: m.exists.return_value = False @@ -756,24 +1006,26 @@ def path_side_effect(p: str) -> MagicMock: mock_subprocess.run.return_value = MagicMock(returncode=0) - args = self._make_args(app='myapp', host='dokku.example.com') + args = self._make_args(app="myapp", host="dokku.example.com") result = cmd_deploy(args) assert result == 0 - @patch('signalwire.cli.dokku.subprocess') - @patch('signalwire.cli.dokku.Path') - def test_deploy_git_push_failure(self, mock_path_cls: MagicMock, mock_subprocess: MagicMock) -> None: + @patch("signalwire.cli.dokku.subprocess") + @patch("signalwire.cli.dokku.Path") + def test_deploy_git_push_failure( + self, mock_path_cls: MagicMock, mock_subprocess: MagicMock + ) -> None: path_instances = {} def path_side_effect(p: str) -> MagicMock: if p not in path_instances: m = MagicMock() path_instances[p] = m - if p == 'Procfile': + if p == "Procfile": m.exists.return_value = True - elif p == 'app.json': + elif p == "app.json": m.exists.return_value = False - elif p == '.git': + elif p == ".git": m.exists.return_value = True else: m.exists.return_value = False @@ -790,24 +1042,26 @@ def path_side_effect(p: str) -> MagicMock: ] mock_subprocess.run.side_effect = results - args = self._make_args(app='myapp', host='dokku.example.com') + args = self._make_args(app="myapp", host="dokku.example.com") result = cmd_deploy(args) assert result == 1 - @patch('signalwire.cli.dokku.subprocess') - @patch('signalwire.cli.dokku.Path') - def test_deploy_initializes_git_if_needed(self, mock_path_cls: MagicMock, mock_subprocess: MagicMock) -> None: + @patch("signalwire.cli.dokku.subprocess") + @patch("signalwire.cli.dokku.Path") + def test_deploy_initializes_git_if_needed( + self, mock_path_cls: MagicMock, mock_subprocess: MagicMock + ) -> None: path_instances = {} def path_side_effect(p: str) -> MagicMock: if p not in path_instances: m = MagicMock() path_instances[p] = m - if p == 'Procfile': + if p == "Procfile": m.exists.return_value = True - elif p == 'app.json': + elif p == "app.json": m.exists.return_value = False - elif p == '.git': + elif p == ".git": m.exists.return_value = False # No git else: m.exists.return_value = False @@ -817,30 +1071,35 @@ def path_side_effect(p: str) -> MagicMock: mock_subprocess.run.return_value = MagicMock(returncode=0) - args = self._make_args(app='myapp', host='dokku.example.com') + args = self._make_args(app="myapp", host="dokku.example.com") result = cmd_deploy(args) assert result == 0 # Check that git init was called calls = mock_subprocess.run.call_args_list - git_init_calls = [c for c in calls if c[0][0] == ['git', 'init']] + git_init_calls = [c for c in calls if c[0][0] == ["git", "init"]] assert len(git_init_calls) == 1 - @patch('signalwire.cli.dokku.prompt', side_effect=['myapp', 'dokku.example.com']) - @patch('signalwire.cli.dokku.subprocess') - @patch('signalwire.cli.dokku.Path') - def test_deploy_prompts_for_missing_info(self, mock_path_cls: MagicMock, mock_subprocess: MagicMock, mock_prompt: MagicMock) -> None: + @patch("signalwire.cli.dokku.prompt", side_effect=["myapp", "dokku.example.com"]) + @patch("signalwire.cli.dokku.subprocess") + @patch("signalwire.cli.dokku.Path") + def test_deploy_prompts_for_missing_info( + self, + mock_path_cls: MagicMock, + mock_subprocess: MagicMock, + mock_prompt: MagicMock, + ) -> None: path_instances = {} def path_side_effect(p: str) -> MagicMock: if p not in path_instances: m = MagicMock() path_instances[p] = m - if p == 'Procfile': + if p == "Procfile": m.exists.return_value = True - elif p == 'app.json': + elif p == "app.json": m.exists.return_value = False - elif p == '.git': + elif p == ".git": m.exists.return_value = True else: m.exists.return_value = False @@ -854,21 +1113,19 @@ def path_side_effect(p: str) -> MagicMock: assert result == 0 assert mock_prompt.call_count == 2 - @patch('signalwire.cli.dokku.subprocess') - @patch('builtins.open', mock_open(read_data='{"name": "from-json"}')) - @patch('signalwire.cli.dokku.Path') - def test_deploy_reads_app_name_from_app_json(self, mock_path_cls: MagicMock, mock_subprocess: MagicMock) -> None: + @patch("signalwire.cli.dokku.subprocess") + @patch("builtins.open", mock_open(read_data='{"name": "from-json"}')) + @patch("signalwire.cli.dokku.Path") + def test_deploy_reads_app_name_from_app_json( + self, mock_path_cls: MagicMock, mock_subprocess: MagicMock + ) -> None: path_instances = {} def path_side_effect(p: str) -> MagicMock: if p not in path_instances: m = MagicMock() path_instances[p] = m - if p == 'Procfile': - m.exists.return_value = True - elif p == 'app.json': - m.exists.return_value = True - elif p == '.git': + if p == "Procfile" or p == "app.json" or p == ".git": m.exists.return_value = True else: m.exists.return_value = False @@ -877,7 +1134,7 @@ def path_side_effect(p: str) -> MagicMock: mock_path_cls.side_effect = path_side_effect mock_subprocess.run.return_value = MagicMock(returncode=0) - args = self._make_args(host='dokku.example.com') # no app, but app.json exists + args = self._make_args(host="dokku.example.com") # no app, but app.json exists result = cmd_deploy(args) assert result == 0 @@ -886,11 +1143,17 @@ def path_side_effect(p: str) -> MagicMock: # cmd_logs Tests # ============================================================================= + class TestCmdLogs: """Tests for the cmd_logs CLI command handler.""" - def _make_args(self, app: str | None = None, host: str | None = None, - tail: bool = False, num: int | None = None) -> argparse.Namespace: + def _make_args( + self, + app: str | None = None, + host: str | None = None, + tail: bool = False, + num: int | None = None, + ) -> argparse.Namespace: args = argparse.Namespace() args.app = app args.host = host @@ -898,43 +1161,53 @@ def _make_args(self, app: str | None = None, host: str | None = None, args.num = num return args - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_logs_basic(self, mock_subprocess: MagicMock) -> None: - args = self._make_args(app='myapp', host='dokku.example.com') + args = self._make_args(app="myapp", host="dokku.example.com") result = cmd_logs(args) assert result == 0 mock_subprocess.run.assert_called_once() cmd = mock_subprocess.run.call_args[0][0] - assert cmd == ['ssh', 'dokku@dokku.example.com', 'logs', 'myapp'] + assert cmd == ["ssh", "dokku@dokku.example.com", "logs", "myapp"] - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_logs_with_tail(self, mock_subprocess: MagicMock) -> None: - args = self._make_args(app='myapp', host='dokku.example.com', tail=True) + args = self._make_args(app="myapp", host="dokku.example.com", tail=True) result = cmd_logs(args) + assert result == 0 cmd = mock_subprocess.run.call_args[0][0] - assert '-t' in cmd + assert "-t" in cmd - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_logs_with_num(self, mock_subprocess: MagicMock) -> None: - args = self._make_args(app='myapp', host='dokku.example.com', num=50) + args = self._make_args(app="myapp", host="dokku.example.com", num=50) result = cmd_logs(args) + assert result == 0 cmd = mock_subprocess.run.call_args[0][0] - assert '--num' in cmd - assert '50' in cmd + assert "--num" in cmd + assert "50" in cmd - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_logs_with_tail_and_num(self, mock_subprocess: MagicMock) -> None: - args = self._make_args(app='myapp', host='dokku.example.com', tail=True, num=100) + args = self._make_args( + app="myapp", host="dokku.example.com", tail=True, num=100 + ) result = cmd_logs(args) + assert result == 0 cmd = mock_subprocess.run.call_args[0][0] - assert '-t' in cmd - assert '--num' in cmd - assert '100' in cmd - - @patch('signalwire.cli.dokku._get_app_name', return_value='fromjson') - @patch('signalwire.cli.dokku.prompt', return_value='dokku.example.com') - @patch('signalwire.cli.dokku.subprocess') - def test_logs_prompts_for_missing_info(self, mock_subprocess: MagicMock, mock_prompt: MagicMock, mock_get_name: MagicMock) -> None: + assert "-t" in cmd + assert "--num" in cmd + assert "100" in cmd + + @patch("signalwire.cli.dokku._get_app_name", return_value="fromjson") + @patch("signalwire.cli.dokku.prompt", return_value="dokku.example.com") + @patch("signalwire.cli.dokku.subprocess") + def test_logs_prompts_for_missing_info( + self, + mock_subprocess: MagicMock, + mock_prompt: MagicMock, + mock_get_name: MagicMock, + ) -> None: args = self._make_args() # no app, no host result = cmd_logs(args) assert result == 0 @@ -946,11 +1219,17 @@ def test_logs_prompts_for_missing_info(self, mock_subprocess: MagicMock, mock_pr # cmd_config Tests # ============================================================================= + class TestCmdConfig: """Tests for the cmd_config CLI command handler.""" - def _make_args(self, action: str = 'show', vars_list: list[str] | None = None, - app: str | None = None, host: str | None = None) -> argparse.Namespace: + def _make_args( + self, + action: str = "show", + vars_list: list[str] | None = None, + app: str | None = None, + host: str | None = None, + ) -> argparse.Namespace: args = argparse.Namespace() args.config_action = action args.vars = vars_list or [] @@ -958,59 +1237,61 @@ def _make_args(self, action: str = 'show', vars_list: list[str] | None = None, args.host = host return args - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_config_show(self, mock_subprocess: MagicMock) -> None: - args = self._make_args(action='show', app='myapp', host='dokku.example.com') + args = self._make_args(action="show", app="myapp", host="dokku.example.com") result = cmd_config(args) assert result == 0 cmd = mock_subprocess.run.call_args[0][0] - assert 'config:show' in cmd - assert 'myapp' in cmd + assert "config:show" in cmd + assert "myapp" in cmd - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_config_set(self, mock_subprocess: MagicMock) -> None: args = self._make_args( - action='set', - vars_list=['KEY=value', 'OTHER=thing'], - app='myapp', - host='dokku.example.com' + action="set", + vars_list=["KEY=value", "OTHER=thing"], + app="myapp", + host="dokku.example.com", ) result = cmd_config(args) assert result == 0 cmd = mock_subprocess.run.call_args[0][0] - assert 'config:set' in cmd - assert 'KEY=value' in cmd - assert 'OTHER=thing' in cmd + assert "config:set" in cmd + assert "KEY=value" in cmd + assert "OTHER=thing" in cmd - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_config_unset(self, mock_subprocess: MagicMock) -> None: args = self._make_args( - action='unset', - vars_list=['KEY'], - app='myapp', - host='dokku.example.com' + action="unset", vars_list=["KEY"], app="myapp", host="dokku.example.com" ) result = cmd_config(args) assert result == 0 cmd = mock_subprocess.run.call_args[0][0] - assert 'config:unset' in cmd - assert 'KEY' in cmd + assert "config:unset" in cmd + assert "KEY" in cmd def test_config_set_no_vars_returns_error(self) -> None: - args = self._make_args(action='set', app='myapp', host='dokku.example.com') + args = self._make_args(action="set", app="myapp", host="dokku.example.com") result = cmd_config(args) assert result == 1 def test_config_unset_no_vars_returns_error(self) -> None: - args = self._make_args(action='unset', app='myapp', host='dokku.example.com') + args = self._make_args(action="unset", app="myapp", host="dokku.example.com") result = cmd_config(args) assert result == 1 - @patch('signalwire.cli.dokku._get_app_name', return_value='fromjson') - @patch('signalwire.cli.dokku.prompt', return_value='dokku.example.com') - @patch('signalwire.cli.dokku.subprocess') - def test_config_prompts_for_missing_info(self, mock_subprocess: MagicMock, mock_prompt: MagicMock, mock_get_name: MagicMock) -> None: - args = self._make_args(action='show') # no app, no host + @patch("signalwire.cli.dokku._get_app_name", return_value="fromjson") + @patch("signalwire.cli.dokku.prompt", return_value="dokku.example.com") + @patch("signalwire.cli.dokku.subprocess") + def test_config_prompts_for_missing_info( + self, + mock_subprocess: MagicMock, + mock_prompt: MagicMock, + mock_get_name: MagicMock, + ) -> None: + args = self._make_args(action="show") # no app, no host result = cmd_config(args) assert result == 0 mock_get_name.assert_called_once() @@ -1021,58 +1302,64 @@ def test_config_prompts_for_missing_info(self, mock_subprocess: MagicMock, mock_ # cmd_scale Tests # ============================================================================= + class TestCmdScale: """Tests for the cmd_scale CLI command handler.""" - def _make_args(self, scale_args: list[str] | None = None, - app: str | None = None, host: str | None = None) -> argparse.Namespace: + def _make_args( + self, + scale_args: list[str] | None = None, + app: str | None = None, + host: str | None = None, + ) -> argparse.Namespace: args = argparse.Namespace() args.scale_args = scale_args or [] args.app = app args.host = host return args - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_scale_show_current(self, mock_subprocess: MagicMock) -> None: - args = self._make_args(app='myapp', host='dokku.example.com') + args = self._make_args(app="myapp", host="dokku.example.com") result = cmd_scale(args) assert result == 0 cmd = mock_subprocess.run.call_args[0][0] - assert 'ps:scale' in cmd - assert 'myapp' in cmd + assert "ps:scale" in cmd + assert "myapp" in cmd # No extra args for showing assert len(cmd) == 4 # ssh, dokku@host, ps:scale, myapp - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_scale_set(self, mock_subprocess: MagicMock) -> None: args = self._make_args( - scale_args=['web=2'], - app='myapp', - host='dokku.example.com' + scale_args=["web=2"], app="myapp", host="dokku.example.com" ) result = cmd_scale(args) assert result == 0 cmd = mock_subprocess.run.call_args[0][0] - assert 'ps:scale' in cmd - assert 'web=2' in cmd + assert "ps:scale" in cmd + assert "web=2" in cmd - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_scale_set_multiple(self, mock_subprocess: MagicMock) -> None: args = self._make_args( - scale_args=['web=2', 'worker=3'], - app='myapp', - host='dokku.example.com' + scale_args=["web=2", "worker=3"], app="myapp", host="dokku.example.com" ) result = cmd_scale(args) assert result == 0 cmd = mock_subprocess.run.call_args[0][0] - assert 'web=2' in cmd - assert 'worker=3' in cmd - - @patch('signalwire.cli.dokku._get_app_name', return_value='fromjson') - @patch('signalwire.cli.dokku.prompt', return_value='dokku.example.com') - @patch('signalwire.cli.dokku.subprocess') - def test_scale_prompts_for_missing_info(self, mock_subprocess: MagicMock, mock_prompt: MagicMock, mock_get_name: MagicMock) -> None: + assert "web=2" in cmd + assert "worker=3" in cmd + + @patch("signalwire.cli.dokku._get_app_name", return_value="fromjson") + @patch("signalwire.cli.dokku.prompt", return_value="dokku.example.com") + @patch("signalwire.cli.dokku.subprocess") + def test_scale_prompts_for_missing_info( + self, + mock_subprocess: MagicMock, + mock_prompt: MagicMock, + mock_get_name: MagicMock, + ) -> None: args = self._make_args() # no app, no host result = cmd_scale(args) assert result == 0 @@ -1084,176 +1371,198 @@ def test_scale_prompts_for_missing_info(self, mock_subprocess: MagicMock, mock_p # _get_app_name Tests # ============================================================================= + class TestGetAppName: """Tests for the _get_app_name helper function.""" - @patch('builtins.open', mock_open(read_data='{"name": "json-app"}')) - @patch('signalwire.cli.dokku.Path') + @patch("builtins.open", mock_open(read_data='{"name": "json-app"}')) + @patch("signalwire.cli.dokku.Path") def test_reads_from_app_json(self, mock_path_cls: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = True mock_path_cls.return_value = mock_path_instance result = _get_app_name() - assert result == 'json-app' + assert result == "json-app" - @patch('signalwire.cli.dokku.prompt', return_value='prompted-app') - @patch('signalwire.cli.dokku.Path') - def test_prompts_when_no_app_json(self, mock_path_cls: MagicMock, mock_prompt: MagicMock) -> None: + @patch("signalwire.cli.dokku.prompt", return_value="prompted-app") + @patch("signalwire.cli.dokku.Path") + def test_prompts_when_no_app_json( + self, mock_path_cls: MagicMock, mock_prompt: MagicMock + ) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance result = _get_app_name() - assert result == 'prompted-app' - - @patch('signalwire.cli.dokku.prompt', return_value='fallback') - @patch('builtins.open', side_effect=json.JSONDecodeError("err", "doc", 0)) - @patch('signalwire.cli.dokku.Path') - def test_prompts_on_invalid_json(self, mock_path_cls: MagicMock, mock_open_fn: MagicMock, mock_prompt: MagicMock) -> None: + assert result == "prompted-app" + + @patch("signalwire.cli.dokku.prompt", return_value="fallback") + @patch("builtins.open", side_effect=json.JSONDecodeError("err", "doc", 0)) + @patch("signalwire.cli.dokku.Path") + def test_prompts_on_invalid_json( + self, mock_path_cls: MagicMock, mock_open_fn: MagicMock, mock_prompt: MagicMock + ) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = True mock_path_cls.return_value = mock_path_instance result = _get_app_name() - assert result == 'fallback' + assert result == "fallback" - @patch('builtins.open', mock_open(read_data='{}')) - @patch('signalwire.cli.dokku.Path') - def test_returns_empty_string_when_name_missing(self, mock_path_cls: MagicMock) -> None: + @patch("builtins.open", mock_open(read_data="{}")) + @patch("signalwire.cli.dokku.Path") + def test_returns_empty_string_when_name_missing( + self, mock_path_cls: MagicMock + ) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = True mock_path_cls.return_value = mock_path_instance result = _get_app_name() - assert result == '' + assert result == "" # ============================================================================= # main() and Argument Parsing Tests # ============================================================================= + class TestMain: """Tests for the main() entry point and argument parsing.""" - @patch('signalwire.cli.dokku.cmd_init', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'init', 'myapp', '--host', 'dokku.test.com']) + @patch("signalwire.cli.dokku.cmd_init", return_value=0) + @patch("sys.argv", ["sw-agent-dokku", "init", "myapp", "--host", "dokku.test.com"]) def test_main_init_command(self, mock_cmd_init: MagicMock) -> None: result = main() assert result == 0 mock_cmd_init.assert_called_once() args = mock_cmd_init.call_args[0][0] - assert args.name == 'myapp' - assert args.host == 'dokku.test.com' - assert args.command == 'init' + assert args.name == "myapp" + assert args.host == "dokku.test.com" + assert args.command == "init" - @patch('signalwire.cli.dokku.cmd_init', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'init', 'myapp', '--cicd']) + @patch("signalwire.cli.dokku.cmd_init", return_value=0) + @patch("sys.argv", ["sw-agent-dokku", "init", "myapp", "--cicd"]) def test_main_init_cicd(self, mock_cmd_init: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_init.call_args[0][0] assert args.cicd is True - @patch('signalwire.cli.dokku.cmd_init', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'init', 'myapp', '--web']) + @patch("signalwire.cli.dokku.cmd_init", return_value=0) + @patch("sys.argv", ["sw-agent-dokku", "init", "myapp", "--web"]) def test_main_init_web(self, mock_cmd_init: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_init.call_args[0][0] assert args.web is True - @patch('signalwire.cli.dokku.cmd_init', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'init', 'myapp', '--force']) + @patch("signalwire.cli.dokku.cmd_init", return_value=0) + @patch("sys.argv", ["sw-agent-dokku", "init", "myapp", "--force"]) def test_main_init_force(self, mock_cmd_init: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_init.call_args[0][0] assert args.force is True - @patch('signalwire.cli.dokku.cmd_init', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'init', 'myapp', '-f']) + @patch("signalwire.cli.dokku.cmd_init", return_value=0) + @patch("sys.argv", ["sw-agent-dokku", "init", "myapp", "-f"]) def test_main_init_force_short(self, mock_cmd_init: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_init.call_args[0][0] assert args.force is True - @patch('signalwire.cli.dokku.cmd_init', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'init', 'myapp', '--dir', '/tmp/out']) + @patch("signalwire.cli.dokku.cmd_init", return_value=0) + @patch("sys.argv", ["sw-agent-dokku", "init", "myapp", "--dir", "build/out"]) def test_main_init_custom_dir(self, mock_cmd_init: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_init.call_args[0][0] - assert args.dir == '/tmp/out' + assert args.dir == "build/out" - @patch('signalwire.cli.dokku.cmd_deploy', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'deploy', '--app', 'myapp', '--host', 'dokku.test.com']) + @patch("signalwire.cli.dokku.cmd_deploy", return_value=0) + @patch( + "sys.argv", + ["sw-agent-dokku", "deploy", "--app", "myapp", "--host", "dokku.test.com"], + ) def test_main_deploy_command(self, mock_cmd_deploy: MagicMock) -> None: result = main() assert result == 0 mock_cmd_deploy.assert_called_once() args = mock_cmd_deploy.call_args[0][0] - assert args.app == 'myapp' - assert args.host == 'dokku.test.com' + assert args.app == "myapp" + assert args.host == "dokku.test.com" - @patch('signalwire.cli.dokku.cmd_deploy', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'deploy', '-a', 'myapp', '-H', 'dokku.test.com']) + @patch("signalwire.cli.dokku.cmd_deploy", return_value=0) + @patch( + "sys.argv", ["sw-agent-dokku", "deploy", "-a", "myapp", "-H", "dokku.test.com"] + ) def test_main_deploy_short_flags(self, mock_cmd_deploy: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_deploy.call_args[0][0] - assert args.app == 'myapp' - assert args.host == 'dokku.test.com' - - @patch('signalwire.cli.dokku.cmd_logs', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'logs', '-a', 'myapp', '-H', 'h', '-t', '-n', '20']) + assert args.app == "myapp" + assert args.host == "dokku.test.com" + + @patch("signalwire.cli.dokku.cmd_logs", return_value=0) + @patch( + "sys.argv", + ["sw-agent-dokku", "logs", "-a", "myapp", "-H", "h", "-t", "-n", "20"], + ) def test_main_logs_command(self, mock_cmd_logs: MagicMock) -> None: result = main() assert result == 0 mock_cmd_logs.assert_called_once() args = mock_cmd_logs.call_args[0][0] - assert args.app == 'myapp' + assert args.app == "myapp" assert args.tail is True assert args.num == 20 - @patch('signalwire.cli.dokku.cmd_config', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'config', 'set', 'KEY=val', '-a', 'myapp', '-H', 'h']) + @patch("signalwire.cli.dokku.cmd_config", return_value=0) + @patch( + "sys.argv", + ["sw-agent-dokku", "config", "set", "KEY=val", "-a", "myapp", "-H", "h"], + ) def test_main_config_set_command(self, mock_cmd_config: MagicMock) -> None: result = main() assert result == 0 mock_cmd_config.assert_called_once() args = mock_cmd_config.call_args[0][0] - assert args.config_action == 'set' - assert args.vars == ['KEY=val'] + assert args.config_action == "set" + assert args.vars == ["KEY=val"] - @patch('signalwire.cli.dokku.cmd_config', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'config', 'show', '-a', 'myapp', '-H', 'h']) + @patch("signalwire.cli.dokku.cmd_config", return_value=0) + @patch("sys.argv", ["sw-agent-dokku", "config", "show", "-a", "myapp", "-H", "h"]) def test_main_config_show_command(self, mock_cmd_config: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_config.call_args[0][0] - assert args.config_action == 'show' + assert args.config_action == "show" - @patch('signalwire.cli.dokku.cmd_config', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'config', 'unset', 'KEY', '-a', 'myapp', '-H', 'h']) + @patch("signalwire.cli.dokku.cmd_config", return_value=0) + @patch( + "sys.argv", + ["sw-agent-dokku", "config", "unset", "KEY", "-a", "myapp", "-H", "h"], + ) def test_main_config_unset_command(self, mock_cmd_config: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_config.call_args[0][0] - assert args.config_action == 'unset' - assert args.vars == ['KEY'] + assert args.config_action == "unset" + assert args.vars == ["KEY"] - @patch('signalwire.cli.dokku.cmd_scale', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'scale', 'web=2', '-a', 'myapp', '-H', 'h']) + @patch("signalwire.cli.dokku.cmd_scale", return_value=0) + @patch("sys.argv", ["sw-agent-dokku", "scale", "web=2", "-a", "myapp", "-H", "h"]) def test_main_scale_command(self, mock_cmd_scale: MagicMock) -> None: result = main() assert result == 0 mock_cmd_scale.assert_called_once() args = mock_cmd_scale.call_args[0][0] - assert args.scale_args == ['web=2'] + assert args.scale_args == ["web=2"] - @patch('sys.argv', ['sw-agent-dokku']) + @patch("sys.argv", ["sw-agent-dokku"]) def test_main_no_command_returns_1(self) -> None: result = main() assert result == 1 @@ -1263,84 +1572,86 @@ def test_main_no_command_returns_1(self) -> None: # Template Content Tests # ============================================================================= + class TestTemplates: """Tests to verify template content is correctly defined.""" def test_procfile_template_has_gunicorn(self) -> None: - assert 'gunicorn' in PROCFILE_TEMPLATE - assert 'UvicornWorker' in PROCFILE_TEMPLATE + assert "gunicorn" in PROCFILE_TEMPLATE + assert "UvicornWorker" in PROCFILE_TEMPLATE def test_runtime_template_has_python(self) -> None: - assert 'python-3.11' in RUNTIME_TEMPLATE + assert "python-3.11" in RUNTIME_TEMPLATE def test_requirements_template_has_deps(self) -> None: - assert 'signalwire-agents' in REQUIREMENTS_TEMPLATE - assert 'gunicorn' in REQUIREMENTS_TEMPLATE - assert 'uvicorn' in REQUIREMENTS_TEMPLATE + assert "signalwire-agents" in REQUIREMENTS_TEMPLATE + assert "gunicorn" in REQUIREMENTS_TEMPLATE + assert "uvicorn" in REQUIREMENTS_TEMPLATE def test_checks_template_has_health(self) -> None: - assert '/health' in CHECKS_TEMPLATE + assert "/health" in CHECKS_TEMPLATE def test_gitignore_template_excludes_env(self) -> None: - assert '.env' in GITIGNORE_TEMPLATE - assert '__pycache__' in GITIGNORE_TEMPLATE + assert ".env" in GITIGNORE_TEMPLATE + assert "__pycache__" in GITIGNORE_TEMPLATE def test_env_example_template_has_placeholders(self) -> None: - assert '{app_name}' in ENV_EXAMPLE_TEMPLATE - assert 'SIGNALWIRE_SPACE_NAME' in ENV_EXAMPLE_TEMPLATE + assert "{app_name}" in ENV_EXAMPLE_TEMPLATE + assert "SIGNALWIRE_SPACE_NAME" in ENV_EXAMPLE_TEMPLATE def test_app_template_has_class_placeholder(self) -> None: - assert '{agent_class}' in APP_TEMPLATE - assert '{agent_name}' in APP_TEMPLATE - assert '{agent_slug}' in APP_TEMPLATE + assert "{agent_class}" in APP_TEMPLATE + assert "{agent_name}" in APP_TEMPLATE + assert "{agent_slug}" in APP_TEMPLATE def test_app_template_with_web_has_server(self) -> None: - assert 'AgentServer' in APP_TEMPLATE_WITH_WEB - assert 'setup_swml_handler' in APP_TEMPLATE_WITH_WEB + assert "AgentServer" in APP_TEMPLATE_WITH_WEB + assert "setup_swml_handler" in APP_TEMPLATE_WITH_WEB def test_app_json_template_is_valid_json_after_format(self) -> None: - content = APP_JSON_TEMPLATE.format(app_name='test') + content = APP_JSON_TEMPLATE.format(app_name="test") data = json.loads(content) - assert data['name'] == 'test' + assert data["name"] == "test" def test_deploy_workflow_template_mentions_dokku(self) -> None: - assert 'dokku-deploy-system' in DEPLOY_WORKFLOW_TEMPLATE + assert "dokku-deploy-system" in DEPLOY_WORKFLOW_TEMPLATE def test_preview_workflow_template_mentions_pull_request(self) -> None: - assert 'pull_request' in PREVIEW_WORKFLOW_TEMPLATE + assert "pull_request" in PREVIEW_WORKFLOW_TEMPLATE def test_dokku_config_template_has_resources(self) -> None: - assert 'resources:' in DOKKU_CONFIG_TEMPLATE - assert 'memory:' in DOKKU_CONFIG_TEMPLATE + assert "resources:" in DOKKU_CONFIG_TEMPLATE + assert "memory:" in DOKKU_CONFIG_TEMPLATE def test_services_template_has_postgres(self) -> None: - assert 'postgres:' in SERVICES_TEMPLATE + assert "postgres:" in SERVICES_TEMPLATE def test_web_index_template_has_html(self) -> None: - assert '' in WEB_INDEX_TEMPLATE - assert '{agent_name}' in WEB_INDEX_TEMPLATE + assert "" in WEB_INDEX_TEMPLATE + assert "{agent_name}" in WEB_INDEX_TEMPLATE def test_deploy_script_template_has_bash(self) -> None: - assert '#!/bin/bash' in DEPLOY_SCRIPT_TEMPLATE - assert '{app_name}' in DEPLOY_SCRIPT_TEMPLATE + assert "#!/bin/bash" in DEPLOY_SCRIPT_TEMPLATE + assert "{app_name}" in DEPLOY_SCRIPT_TEMPLATE def test_readme_simple_template_has_deploy(self) -> None: - assert 'deploy' in README_SIMPLE_TEMPLATE.lower() + assert "deploy" in README_SIMPLE_TEMPLATE.lower() def test_readme_cicd_template_has_github(self) -> None: - assert 'GitHub' in README_CICD_TEMPLATE + assert "GitHub" in README_CICD_TEMPLATE # ============================================================================= # Edge Case and Integration Tests # ============================================================================= + class TestEdgeCases: """Tests for various edge cases and special scenarios.""" def test_generate_password_length_zero(self) -> None: pw = generate_password(length=0) - assert pw == '' + assert pw == "" def test_generate_password_length_one(self) -> None: pw = generate_password(length=1) @@ -1358,21 +1669,21 @@ def test_agent_class_name_all_caps(self) -> None: assert gen.agent_slug == "abc" assert gen.agent_class == "AbcAgent" - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_deploy_remote_url_format(self, mock_subprocess: MagicMock) -> None: """Verify the dokku remote URL is correctly formed.""" - with patch('signalwire.cli.dokku.Path') as mock_path_cls: + with patch("signalwire.cli.dokku.Path") as mock_path_cls: path_instances = {} def path_side_effect(p: str) -> MagicMock: if p not in path_instances: m = MagicMock() path_instances[p] = m - if p == 'Procfile': + if p == "Procfile": m.exists.return_value = True - elif p == 'app.json': + elif p == "app.json": m.exists.return_value = False - elif p == '.git': + elif p == ".git": m.exists.return_value = True else: m.exists.return_value = False @@ -1381,36 +1692,36 @@ def path_side_effect(p: str) -> MagicMock: mock_path_cls.side_effect = path_side_effect mock_subprocess.run.return_value = MagicMock(returncode=0) - args = argparse.Namespace(app='myapp', host='dokku.host.com') + args = argparse.Namespace(app="myapp", host="dokku.host.com") cmd_deploy(args) # Find the remote add call for c in mock_subprocess.run.call_args_list: call_args = c[0][0] - if 'remote' in call_args and 'add' in call_args: - assert 'dokku@dokku.host.com:myapp' in call_args + if "remote" in call_args and "add" in call_args: + assert "dokku@dokku.host.com:myapp" in call_args break - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_logs_command_structure(self, mock_subprocess: MagicMock) -> None: """Verify the SSH log command is correctly formed.""" args = argparse.Namespace( - app='testapp', host='my.dokku.host', tail=True, num=200 + app="testapp", host="my.dokku.host", tail=True, num=200 ) cmd_logs(args) cmd = mock_subprocess.run.call_args[0][0] - assert cmd[0] == 'ssh' - assert cmd[1] == 'dokku@my.dokku.host' - assert cmd[2] == 'logs' - assert cmd[3] == 'testapp' - assert '-t' in cmd - assert '--num' in cmd - assert '200' in cmd + assert cmd[0] == "ssh" + assert cmd[1] == "dokku@my.dokku.host" + assert cmd[2] == "logs" + assert cmd[3] == "testapp" + assert "-t" in cmd + assert "--num" in cmd + assert "200" in cmd def test_config_set_empty_vars_list(self) -> None: """Empty vars list (not None, but []) should still fail.""" args = argparse.Namespace( - config_action='set', vars=[], app='myapp', host='dokku.example.com' + config_action="set", vars=[], app="myapp", host="dokku.example.com" ) result = cmd_config(args) assert result == 1 @@ -1418,44 +1729,58 @@ def test_config_set_empty_vars_list(self) -> None: def test_config_unset_empty_vars_list(self) -> None: """Empty vars list (not None, but []) should still fail.""" args = argparse.Namespace( - config_action='unset', vars=[], app='myapp', host='dokku.example.com' + config_action="unset", vars=[], app="myapp", host="dokku.example.com" ) result = cmd_config(args) assert result == 1 - @patch('signalwire.cli.dokku.subprocess') + @patch("signalwire.cli.dokku.subprocess") def test_scale_show_no_extra_args(self, mock_subprocess: MagicMock) -> None: """When scale_args is empty, just show current scale without extra args.""" - args = argparse.Namespace( - scale_args=[], app='myapp', host='dokku.example.com' - ) + args = argparse.Namespace(scale_args=[], app="myapp", host="dokku.example.com") cmd_scale(args) cmd = mock_subprocess.run.call_args[0][0] # Should be exactly: ssh dokku@host ps:scale myapp - assert cmd == ['ssh', 'dokku@dokku.example.com', 'ps:scale', 'myapp'] + assert cmd == ["ssh", "dokku@dokku.example.com", "ps:scale", "myapp"] def test_generate_creates_project_dir_if_missing(self, tmp_path: Path) -> None: """generate() should create the project directory with parents.""" deep_dir = tmp_path / "a" / "b" / "c" - gen = DokkuProjectGenerator("myapp", { - 'project_dir': str(deep_dir), - 'dokku_host': 'dokku.example.com', - }) + gen = DokkuProjectGenerator( + "myapp", + { + "project_dir": str(deep_dir), + "dokku_host": "dokku.example.com", + }, + ) result = gen.generate() assert result is True assert deep_dir.exists() - @patch('signalwire.cli.dokku.cmd_init', return_value=0) - @patch('sys.argv', ['sw-agent-dokku', 'init', 'my-app', '--cicd', '--web', - '--host', 'h', '--dir', '/tmp/d', '-f']) + @patch("signalwire.cli.dokku.cmd_init", return_value=0) + @patch( + "sys.argv", + [ + "sw-agent-dokku", + "init", + "my-app", + "--cicd", + "--web", + "--host", + "h", + "--dir", + "build/d", + "-f", + ], + ) def test_main_all_init_flags(self, mock_cmd_init: MagicMock) -> None: """All init flags can be passed together.""" result = main() assert result == 0 args = mock_cmd_init.call_args[0][0] - assert args.name == 'my-app' + assert args.name == "my-app" assert args.cicd is True assert args.web is True - assert args.host == 'h' - assert args.dir == '/tmp/d' + assert args.host == "h" + assert args.dir == "build/d" assert args.force is True diff --git a/tests/unit/cli/test_init_project.py b/tests/unit/cli/test_init_project.py index e6e47cbb..a74b3614 100644 --- a/tests/unit/cli/test_init_project.py +++ b/tests/unit/cli/test_init_project.py @@ -49,24 +49,26 @@ # Colors Class Tests # ============================================================================= + class TestColors: """Tests for the ANSI color constants.""" def test_colors_has_expected_attributes(self) -> None: - assert Colors.RED == '\033[0;31m' - assert Colors.GREEN == '\033[0;32m' - assert Colors.YELLOW == '\033[1;33m' - assert Colors.BLUE == '\033[0;34m' - assert Colors.CYAN == '\033[0;36m' - assert Colors.BOLD == '\033[1m' - assert Colors.DIM == '\033[2m' - assert Colors.NC == '\033[0m' + assert Colors.RED == "\033[0;31m" + assert Colors.GREEN == "\033[0;32m" + assert Colors.YELLOW == "\033[1;33m" + assert Colors.BLUE == "\033[0;34m" + assert Colors.CYAN == "\033[0;36m" + assert Colors.BOLD == "\033[1m" + assert Colors.DIM == "\033[2m" + assert Colors.NC == "\033[0m" # ============================================================================= # Print Utility Tests # ============================================================================= + class TestPrintUtilities: """Tests for print_step, print_success, print_warning, print_error.""" @@ -99,79 +101,82 @@ def test_print_error(self, capsys: pytest.CaptureFixture[str]) -> None: # Prompt Function Tests # ============================================================================= + class TestPromptFunctions: """Tests for interactive prompt functions.""" - @patch('builtins.input', return_value='') + @patch("builtins.input", return_value="") def test_prompt_returns_default_when_empty(self, mock_input: MagicMock) -> None: result = prompt("Name", "default_val") assert result == "default_val" mock_input.assert_called_once() - @patch('builtins.input', return_value='custom') + @patch("builtins.input", return_value="custom") def test_prompt_returns_user_input(self, mock_input: MagicMock) -> None: result = prompt("Name", "default_val") assert result == "custom" - @patch('builtins.input', return_value=' spaced ') + @patch("builtins.input", return_value=" spaced ") def test_prompt_strips_whitespace(self, mock_input: MagicMock) -> None: result = prompt("Name", "default_val") assert result == "spaced" - @patch('builtins.input', return_value='hello') + @patch("builtins.input", return_value="hello") def test_prompt_without_default(self, mock_input: MagicMock) -> None: result = prompt("Name") assert result == "hello" # Should not include bracket notation mock_input.assert_called_once_with("Name: ") - @patch('builtins.input', return_value='') + @patch("builtins.input", return_value="") def test_prompt_yes_no_default_true(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=True) assert result is True - @patch('builtins.input', return_value='') + @patch("builtins.input", return_value="") def test_prompt_yes_no_default_false(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=False) assert result is False - @patch('builtins.input', return_value='y') + @patch("builtins.input", return_value="y") def test_prompt_yes_no_explicit_yes(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=False) assert result is True - @patch('builtins.input', return_value='yes') + @patch("builtins.input", return_value="yes") def test_prompt_yes_no_explicit_yes_full(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=False) assert result is True - @patch('builtins.input', return_value='n') + @patch("builtins.input", return_value="n") def test_prompt_yes_no_explicit_no(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=True) assert result is False - @patch('builtins.input', return_value='') + @patch("builtins.input", return_value="") def test_prompt_select_returns_default(self, mock_input: MagicMock) -> None: result = prompt_select("Pick:", ["A", "B", "C"], default=2) assert result == 2 - @patch('builtins.input', return_value='3') + @patch("builtins.input", return_value="3") def test_prompt_select_returns_chosen(self, mock_input: MagicMock) -> None: result = prompt_select("Pick:", ["A", "B", "C"], default=1) assert result == 3 - @patch('builtins.input', side_effect=['bad', '0', '4', '2']) - def test_prompt_select_rejects_invalid_then_accepts(self, mock_input: MagicMock) -> None: + @patch("builtins.input", side_effect=["bad", "0", "4", "2"]) + def test_prompt_select_rejects_invalid_then_accepts( + self, mock_input: MagicMock + ) -> None: result = prompt_select("Pick:", ["A", "B", "C"], default=1) assert result == 2 assert mock_input.call_count == 4 - @patch('builtins.input', side_effect=['1', '']) + @patch("builtins.input", side_effect=["1", ""]) def test_prompt_multiselect_toggle_and_accept(self, mock_input: MagicMock) -> None: result = prompt_multiselect("Features:", ["A", "B"], [False, True]) assert result == [True, True] - @patch('builtins.input', side_effect=['']) + @patch("builtins.input", side_effect=[""]) def test_prompt_multiselect_accept_defaults(self, mock_input: MagicMock) -> None: result = prompt_multiselect("Features:", ["A", "B"], [True, False]) assert result == [True, False] @@ -181,6 +186,7 @@ def test_prompt_multiselect_accept_defaults(self, mock_input: MagicMock) -> None # Mask Token Tests # ============================================================================= + class TestMaskToken: """Tests for the mask_token helper.""" @@ -201,37 +207,44 @@ def test_mask_long_token(self) -> None: # Get Env Credentials Tests # ============================================================================= + class TestGetEnvCredentials: """Tests for get_env_credentials.""" - @patch.dict(os.environ, { - 'SIGNALWIRE_SPACE_NAME': 'myspace', - 'SIGNALWIRE_PROJECT_ID': 'proj123', - 'SIGNALWIRE_API_TOKEN': 'tok456', - }) + @patch.dict( + os.environ, + { + "SIGNALWIRE_SPACE_NAME": "myspace", + "SIGNALWIRE_PROJECT_ID": "proj123", + "SIGNALWIRE_API_TOKEN": "tok456", + }, + ) def test_returns_env_values(self) -> None: creds = get_env_credentials() - assert creds['space'] == 'myspace' - assert creds['project'] == 'proj123' - assert creds['token'] == 'tok456' # noqa: S105 + assert creds["space"] == "myspace" + assert creds["project"] == "proj123" + assert creds["token"] == "tok456" @patch.dict(os.environ, {}, clear=True) def test_returns_empty_when_unset(self) -> None: # Remove any env vars that might be set for key in [ - 'SIGNALWIRE_SPACE_NAME', 'SIGNALWIRE_PROJECT_ID', 'SIGNALWIRE_API_TOKEN', + "SIGNALWIRE_SPACE_NAME", + "SIGNALWIRE_PROJECT_ID", + "SIGNALWIRE_API_TOKEN", ]: os.environ.pop(key, None) creds = get_env_credentials() - assert creds['space'] == '' - assert creds['project'] == '' - assert creds['token'] == '' + assert creds["space"] == "" + assert creds["project"] == "" + assert creds["token"] == "" # ============================================================================= # Generate Password Tests # ============================================================================= + class TestGeneratePassword: """Tests for generate_password.""" @@ -253,62 +266,63 @@ def test_passwords_are_unique(self) -> None: # Template Generation Tests # ============================================================================= + class TestGetAgentTemplate: """Tests for get_agent_template.""" def test_basic_agent_template(self) -> None: - features = {'example_tool': True, 'debug_webhooks': False, 'basic_auth': False} - result = get_agent_template('basic', features) - assert 'class MainAgent(AgentBase):' in result - assert 'from signalwire import AgentBase' in result - assert 'FunctionResult' in result - assert 'get_info' in result + features = {"example_tool": True, "debug_webhooks": False, "basic_auth": False} + result = get_agent_template("basic", features) + assert "class MainAgent(AgentBase):" in result + assert "from signalwire import AgentBase" in result + assert "FunctionResult" in result + assert "get_info" in result def test_agent_template_without_tool(self) -> None: - features = {'example_tool': False, 'debug_webhooks': False, 'basic_auth': False} - result = get_agent_template('basic', features) - assert 'class MainAgent(AgentBase):' in result - assert 'get_info' not in result - assert 'FunctionResult' not in result + features = {"example_tool": False, "debug_webhooks": False, "basic_auth": False} + result = get_agent_template("basic", features) + assert "class MainAgent(AgentBase):" in result + assert "get_info" not in result + assert "FunctionResult" not in result def test_agent_template_with_all_features(self) -> None: - features = {'example_tool': True, 'debug_webhooks': True, 'basic_auth': True} - result = get_agent_template('full', features) - assert 'import os' in result - assert '_configure_debug_webhooks' in result - assert 'SWML_BASIC_AUTH_USER' in result - assert 'get_info' in result + features = {"example_tool": True, "debug_webhooks": True, "basic_auth": True} + result = get_agent_template("full", features) + assert "import os" in result + assert "_configure_debug_webhooks" in result + assert "SWML_BASIC_AUTH_USER" in result + assert "get_info" in result def test_agent_template_with_debug_only(self) -> None: - features = {'example_tool': False, 'debug_webhooks': True, 'basic_auth': False} - result = get_agent_template('basic', features) - assert 'import os' in result - assert '_configure_debug_webhooks' in result - assert 'on_summary' in result + features = {"example_tool": False, "debug_webhooks": True, "basic_auth": False} + result = get_agent_template("basic", features) + assert "import os" in result + assert "_configure_debug_webhooks" in result + assert "on_summary" in result class TestGetAppTemplate: """Tests for get_app_template.""" def test_basic_app_template(self) -> None: - features = {'debug_webhooks': False, 'web_ui': False} + features = {"debug_webhooks": False, "web_ui": False} result = get_app_template(features) - assert 'from signalwire import AgentServer' in result - assert 'def main():' in result - assert 'server.run()' in result + assert "from signalwire import AgentServer" in result + assert "def main():" in result + assert "server.run()" in result def test_app_template_with_debug(self) -> None: - features = {'debug_webhooks': True, 'web_ui': False} + features = {"debug_webhooks": True, "web_ui": False} result = get_app_template(features) - assert 'print_debug_data' in result - assert 'print_post_prompt_data' in result - assert '/debug' in result - assert '/post_prompt' in result + assert "print_debug_data" in result + assert "print_post_prompt_data" in result + assert "/debug" in result + assert "/post_prompt" in result def test_app_template_with_web_ui(self) -> None: - features = {'debug_webhooks': False, 'web_ui': True} + features = {"debug_webhooks": False, "web_ui": True} result = get_app_template(features) - assert 'serve_static_files' in result + assert "serve_static_files" in result class TestGetTestTemplate: @@ -316,30 +330,30 @@ class TestGetTestTemplate: def test_test_template_with_tool(self) -> None: result = get_test_template(has_tool=True) - assert 'test_get_info_function' in result - assert 'TestDirectImport' in result - assert 'test_agent_has_tools' in result + assert "test_get_info_function" in result + assert "TestDirectImport" in result + assert "test_agent_has_tools" in result def test_test_template_without_tool(self) -> None: result = get_test_template(has_tool=False) - assert 'TestDirectImport' not in result - assert 'test_agent_has_tools' not in result + assert "TestDirectImport" not in result + assert "test_agent_has_tools" not in result class TestGetReadmeTemplate: """Tests for get_readme_template.""" def test_basic_readme(self) -> None: - features = {'debug_webhooks': False, 'web_ui': False} + features = {"debug_webhooks": False, "web_ui": False} result = get_readme_template("test-project", features) - assert '# test-project' in result - assert '/swml' in result + assert "# test-project" in result + assert "/swml" in result def test_readme_with_debug(self) -> None: - features = {'debug_webhooks': True, 'web_ui': False} + features = {"debug_webhooks": True, "web_ui": False} result = get_readme_template("test-project", features) - assert '/debug' in result - assert '/post_prompt' in result + assert "/debug" in result + assert "/post_prompt" in result class TestGetWebIndexTemplate: @@ -347,114 +361,124 @@ class TestGetWebIndexTemplate: def test_web_template_has_html(self) -> None: result = get_web_index_template() - assert '' in result - assert 'SignalWire Agent' in result + assert "" in result + assert "SignalWire Agent" in result # ============================================================================= # ProjectGenerator Tests # ============================================================================= + class TestProjectGenerator: """Tests for the ProjectGenerator class.""" - def _make_config(self, platform: str = 'local', **overrides: Any) -> dict[str, Any]: + def _make_config(self, platform: str = "local", **overrides: Any) -> dict[str, Any]: + # Relative, not '/tmp/...': the project rule forbids a hardcoded /tmp, and a + # rooted POSIX path is not portable anyway. Nothing here touches the disk -- + # every generation path is mocked -- so no real directory is needed. Tests + # that assert on the value pass an explicit `tmp_path`-derived override. config: dict[str, Any] = { - 'project_name': 'test-agent', - 'project_dir': '/tmp/test-agent', # noqa: S108 - 'platform': platform, - 'agent_type': 'basic', - 'features': { - 'debug_webhooks': False, - 'post_prompt': False, - 'web_ui': False, - 'example_tool': True, - 'tests': True, - 'basic_auth': False, + "project_name": "test-agent", + "project_dir": "test-agent", + "platform": platform, + "agent_type": "basic", + "features": { + "debug_webhooks": False, + "post_prompt": False, + "web_ui": False, + "example_tool": True, + "tests": True, + "basic_auth": False, }, - 'credentials': {'space': '', 'project': '', 'token': ''}, - 'create_venv': False, - 'cloud_config': {}, + "credentials": {"space": "", "project": "", "token": ""}, + "create_venv": False, + "cloud_config": {}, } config.update(overrides) return config - def test_constructor(self) -> None: - config = self._make_config() + def test_constructor(self, tmp_path: Path) -> None: + target = tmp_path / "test-agent" + config = self._make_config(project_dir=str(target)) gen = ProjectGenerator(config) - assert gen.project_name == 'test-agent' - assert gen.platform == 'local' - assert gen.project_dir == Path('/tmp/test-agent') # noqa: S108 + assert gen.project_name == "test-agent" + assert gen.platform == "local" + assert gen.project_dir == target - @patch.object(ProjectGenerator, '_generate_local', return_value=True) + @patch.object(ProjectGenerator, "_generate_local", return_value=True) def test_generate_dispatches_to_local(self, mock_gen: MagicMock) -> None: - config = self._make_config(platform='local') + config = self._make_config(platform="local") gen = ProjectGenerator(config) result = gen.generate() assert result is True mock_gen.assert_called_once() - @patch.object(ProjectGenerator, '_generate_aws', return_value=True) + @patch.object(ProjectGenerator, "_generate_aws", return_value=True) def test_generate_dispatches_to_aws(self, mock_gen: MagicMock) -> None: - config = self._make_config(platform='aws') + config = self._make_config(platform="aws") gen = ProjectGenerator(config) result = gen.generate() assert result is True mock_gen.assert_called_once() - @patch.object(ProjectGenerator, '_generate_gcp', return_value=True) + @patch.object(ProjectGenerator, "_generate_gcp", return_value=True) def test_generate_dispatches_to_gcp(self, mock_gen: MagicMock) -> None: - config = self._make_config(platform='gcp') + config = self._make_config(platform="gcp") gen = ProjectGenerator(config) result = gen.generate() assert result is True mock_gen.assert_called_once() - @patch.object(ProjectGenerator, '_generate_azure', return_value=True) + @patch.object(ProjectGenerator, "_generate_azure", return_value=True) def test_generate_dispatches_to_azure(self, mock_gen: MagicMock) -> None: - config = self._make_config(platform='azure') + config = self._make_config(platform="azure") gen = ProjectGenerator(config) result = gen.generate() assert result is True mock_gen.assert_called_once() def test_generate_unknown_platform(self) -> None: - config = self._make_config(platform='unknown_cloud') + config = self._make_config(platform="unknown_cloud") gen = ProjectGenerator(config) result = gen.generate() assert result is False def test_generate_catches_exception(self) -> None: - config = self._make_config(platform='local') + config = self._make_config(platform="local") gen = ProjectGenerator(config) - with patch.object(gen, '_generate_local', side_effect=PermissionError("denied")): + with patch.object( + gen, "_generate_local", side_effect=PermissionError("denied") + ): result = gen.generate() assert result is False def test_get_template_vars(self) -> None: config = self._make_config( - platform='aws', - cloud_config={'region': 'us-west-2'}, + platform="aws", + cloud_config={"region": "us-west-2"}, ) gen = ProjectGenerator(config) tvars = gen._get_template_vars() - assert tvars['agent_name'] == 'test-agent' - assert tvars['agent_name_slug'] == 'test-agent' - assert tvars['agent_class'] == 'TestAgentAgent' - assert tvars['function_name'] == 'test-agent' - assert tvars['region'] == 'us-west-2' - assert tvars['auth_user'] == 'admin' - assert len(tvars['auth_password']) == 16 + assert tvars["agent_name"] == "test-agent" + assert tvars["agent_name_slug"] == "test-agent" + assert tvars["agent_class"] == "TestAgentAgent" + assert tvars["function_name"] == "test-agent" + assert tvars["region"] == "us-west-2" + assert tvars["auth_user"] == "admin" + assert len(tvars["auth_password"]) == 16 def test_get_template_vars_default_region(self) -> None: - config = self._make_config(platform='aws', cloud_config={}) + config = self._make_config(platform="aws", cloud_config={}) gen = ProjectGenerator(config) tvars = gen._get_template_vars() - assert tvars['region'] == 'us-east-1' + assert tvars["region"] == "us-east-1" - @patch('pathlib.Path.mkdir') - @patch('pathlib.Path.write_text') - def test_generate_local_creates_structure(self, mock_write: MagicMock, mock_mkdir: MagicMock) -> None: + @patch("pathlib.Path.mkdir") + @patch("pathlib.Path.write_text") + def test_generate_local_creates_structure( + self, mock_write: MagicMock, mock_mkdir: MagicMock + ) -> None: config = self._make_config() gen = ProjectGenerator(config) result = gen._generate_local() @@ -469,76 +493,78 @@ def test_generate_local_creates_structure(self, mock_write: MagicMock, mock_mkdi # run_quick Tests # ============================================================================= + class TestRunQuick: """Tests for run_quick configuration builder.""" def test_basic_quick_mode(self) -> None: args = argparse.Namespace( - platform='local', region=None, type='basic', no_venv=False + platform="local", region=None, type="basic", no_venv=False ) - config = run_quick('myagent', args) - assert config['project_name'] == 'myagent' - assert config['platform'] == 'local' - assert config['agent_type'] == 'basic' - assert config['features']['example_tool'] is True - assert config['features']['debug_webhooks'] is False - assert config['create_venv'] is True + config = run_quick("myagent", args) + assert config["project_name"] == "myagent" + assert config["platform"] == "local" + assert config["agent_type"] == "basic" + assert config["features"]["example_tool"] is True + assert config["features"]["debug_webhooks"] is False + assert config["create_venv"] is True def test_full_type_quick_mode(self) -> None: args = argparse.Namespace( - platform='local', region=None, type='full', no_venv=True + platform="local", region=None, type="full", no_venv=True ) - config = run_quick('myagent', args) - assert config['agent_type'] == 'full' - assert config['features']['debug_webhooks'] is True - assert config['features']['web_ui'] is True - assert config['features']['basic_auth'] is True - assert config['create_venv'] is False + config = run_quick("myagent", args) + assert config["agent_type"] == "full" + assert config["features"]["debug_webhooks"] is True + assert config["features"]["web_ui"] is True + assert config["features"]["basic_auth"] is True + assert config["create_venv"] is False def test_aws_platform_quick_mode(self) -> None: args = argparse.Namespace( - platform='aws', region='us-west-2', type='basic', no_venv=False + platform="aws", region="us-west-2", type="basic", no_venv=False ) - config = run_quick('myagent', args) - assert config['platform'] == 'aws' - assert config['cloud_config']['region'] == 'us-west-2' + config = run_quick("myagent", args) + assert config["platform"] == "aws" + assert config["cloud_config"]["region"] == "us-west-2" # Cloud platforms don't create venvs - assert config['create_venv'] is False + assert config["create_venv"] is False # Cloud platforms have simplified features - assert config['features']['tests'] is False - assert config['features']['basic_auth'] is True + assert config["features"]["tests"] is False + assert config["features"]["basic_auth"] is True def test_aws_default_region(self) -> None: args = argparse.Namespace( - platform='aws', region=None, type='basic', no_venv=False + platform="aws", region=None, type="basic", no_venv=False ) - config = run_quick('myagent', args) - assert config['cloud_config']['region'] == 'us-east-1' + config = run_quick("myagent", args) + assert config["cloud_config"]["region"] == "us-east-1" def test_azure_includes_resource_group(self) -> None: args = argparse.Namespace( - platform='azure', region='eastus', type='basic', no_venv=False + platform="azure", region="eastus", type="basic", no_venv=False ) - config = run_quick('myagent', args) - assert config['cloud_config']['resource_group'] == 'myagent-rg' + config = run_quick("myagent", args) + assert config["cloud_config"]["resource_group"] == "myagent-rg" def test_project_dir_is_absolute(self) -> None: args = argparse.Namespace( - platform='local', region=None, type='basic', no_venv=False + platform="local", region=None, type="basic", no_venv=False ) - config = run_quick('myagent', args) - assert Path(config['project_dir']).is_absolute() + config = run_quick("myagent", args) + assert Path(config["project_dir"]).is_absolute() # ============================================================================= # main() Entry Point Tests # ============================================================================= + class TestMain: """Tests for the main() CLI entry point.""" - @patch('signalwire.cli.init_project.ProjectGenerator') - @patch('sys.argv', ['sw-agent-init', 'testproject', '--type', 'basic', '--no-venv']) + @patch("signalwire.cli.init_project.ProjectGenerator") + @patch("sys.argv", ["sw-agent-init", "testproject", "--type", "basic", "--no-venv"]) def test_main_quick_mode(self, mock_gen_class: MagicMock) -> None: mock_gen = Mock() mock_gen.generate.return_value = True @@ -548,12 +574,12 @@ def test_main_quick_mode(self, mock_gen_class: MagicMock) -> None: mock_gen_class.assert_called_once() config = mock_gen_class.call_args[0][0] - assert config['project_name'] == 'testproject' - assert config['agent_type'] == 'basic' + assert config["project_name"] == "testproject" + assert config["agent_type"] == "basic" mock_gen.generate.assert_called_once() - @patch('signalwire.cli.init_project.ProjectGenerator') - @patch('sys.argv', ['sw-agent-init', 'testproject', '--type', 'full', '--no-venv']) + @patch("signalwire.cli.init_project.ProjectGenerator") + @patch("sys.argv", ["sw-agent-init", "testproject", "--type", "full", "--no-venv"]) def test_main_quick_mode_full(self, mock_gen_class: MagicMock) -> None: mock_gen = Mock() mock_gen.generate.return_value = True @@ -562,11 +588,11 @@ def test_main_quick_mode_full(self, mock_gen_class: MagicMock) -> None: main() config = mock_gen_class.call_args[0][0] - assert config['agent_type'] == 'full' - assert config['features']['debug_webhooks'] is True + assert config["agent_type"] == "full" + assert config["features"]["debug_webhooks"] is True - @patch('signalwire.cli.init_project.ProjectGenerator') - @patch('sys.argv', ['sw-agent-init', 'testproject', '-p', 'aws', '--no-venv']) + @patch("signalwire.cli.init_project.ProjectGenerator") + @patch("sys.argv", ["sw-agent-init", "testproject", "-p", "aws", "--no-venv"]) def test_main_aws_platform(self, mock_gen_class: MagicMock) -> None: mock_gen = Mock() mock_gen.generate.return_value = True @@ -575,22 +601,29 @@ def test_main_aws_platform(self, mock_gen_class: MagicMock) -> None: main() config = mock_gen_class.call_args[0][0] - assert config['platform'] == 'aws' + assert config["platform"] == "aws" - @patch('signalwire.cli.init_project.ProjectGenerator') - @patch('sys.argv', ['sw-agent-init', 'testproject', '--no-venv', '--dir', '/tmp/custom']) # noqa: S108 - def test_main_custom_dir(self, mock_gen_class: MagicMock) -> None: + @patch("signalwire.cli.init_project.ProjectGenerator") + def test_main_custom_dir(self, mock_gen_class: MagicMock, tmp_path: Path) -> None: mock_gen = Mock() mock_gen.generate.return_value = True mock_gen_class.return_value = mock_gen - main() + custom = tmp_path / "custom" + with patch( + "sys.argv", + ["sw-agent-init", "testproject", "--no-venv", "--dir", str(custom)], + ): + main() config = mock_gen_class.call_args[0][0] - assert '/tmp/custom' in config['project_dir'] # noqa: S108 + # main() builds `(Path(args.dir) / args.name).absolute()`. Compare Paths -- + # a substring check against a POSIX literal fails on Windows, where the + # separator is `\` and a rooted POSIX path gains a drive letter. + assert Path(config["project_dir"]) == (custom / "testproject").absolute() - @patch('signalwire.cli.init_project.ProjectGenerator') - @patch('sys.argv', ['sw-agent-init', 'testproject', '--no-venv']) + @patch("signalwire.cli.init_project.ProjectGenerator") + @patch("sys.argv", ["sw-agent-init", "testproject", "--no-venv"]) def test_main_generate_failure_exits(self, mock_gen_class: MagicMock) -> None: mock_gen = Mock() mock_gen.generate.return_value = False @@ -605,16 +638,17 @@ def test_main_generate_failure_exits(self, mock_gen_class: MagicMock) -> None: # Constants Tests # ============================================================================= + class TestConstants: """Tests for module-level constants.""" def test_cloud_platforms_has_expected_keys(self) -> None: - assert 'local' in CLOUD_PLATFORMS - assert 'aws' in CLOUD_PLATFORMS - assert 'gcp' in CLOUD_PLATFORMS - assert 'azure' in CLOUD_PLATFORMS + assert "local" in CLOUD_PLATFORMS + assert "aws" in CLOUD_PLATFORMS + assert "gcp" in CLOUD_PLATFORMS + assert "azure" in CLOUD_PLATFORMS def test_default_regions(self) -> None: - assert DEFAULT_REGIONS['aws'] == 'us-east-1' - assert DEFAULT_REGIONS['gcp'] == 'us-central1' - assert DEFAULT_REGIONS['azure'] == 'eastus' + assert DEFAULT_REGIONS["aws"] == "us-east-1" + assert DEFAULT_REGIONS["gcp"] == "us-central1" + assert DEFAULT_REGIONS["azure"] == "eastus" diff --git a/tests/unit/cli/test_service_loader.py b/tests/unit/cli/test_service_loader.py index 71f6cc0c..d93307f1 100644 --- a/tests/unit/cli/test_service_loader.py +++ b/tests/unit/cli/test_service_loader.py @@ -23,14 +23,9 @@ """ import pytest -import sys -import types -import importlib -import importlib.util from pathlib import Path -from typing import Any # noqa: E402 -from unittest.mock import Mock, patch, MagicMock, PropertyMock, call -from io import StringIO +from typing import Any +from unittest.mock import Mock, patch, MagicMock from signalwire.cli.core.service_loader import ( ServiceCapture, @@ -38,7 +33,6 @@ load_agent_from_file, discover_agents_in_file, simulate_request_to_service, - DEPENDENCIES_AVAILABLE, ) from signalwire.core.swml_service import SWMLService from signalwire.core.agent_base import AgentBase @@ -48,6 +42,7 @@ # Helper Fixtures # ============================================================================= + @pytest.fixture def capturer() -> ServiceCapture: """Create a fresh ServiceCapture instance.""" @@ -57,11 +52,14 @@ def capturer() -> ServiceCapture: class _FakeSWMLService: """A plain class that is NOT a subclass of AgentBase. Used so isinstance(obj, AgentBase) returns False.""" + name: str = "" route: str = "" -def _make_mock_service(name: str = "test_service", route: str = "/test") -> _FakeSWMLService: +def _make_mock_service( + name: str = "test_service", route: str = "/test" +) -> _FakeSWMLService: """Create an object that is NOT an AgentBase instance.""" svc = _FakeSWMLService() svc.name = name @@ -91,13 +89,18 @@ def _make_mock_agent( # ServiceCapture.__init__ Tests # ============================================================================= + class TestServiceCaptureInit: """Tests for ServiceCapture initialization.""" - def test_init_creates_empty_captured_services(self, capturer: ServiceCapture) -> None: + def test_init_creates_empty_captured_services( + self, capturer: ServiceCapture + ) -> None: assert capturer.captured_services == [] - def test_init_creates_empty_original_methods(self, capturer: ServiceCapture) -> None: + def test_init_creates_empty_original_methods( + self, capturer: ServiceCapture + ) -> None: assert capturer.original_methods == {} @@ -105,45 +108,60 @@ def test_init_creates_empty_original_methods(self, capturer: ServiceCapture) -> # ServiceCapture.capture Error Handling Tests # ============================================================================= + class TestServiceCaptureErrors: """Tests for ServiceCapture.capture error conditions.""" - def test_capture_file_not_found(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_file_not_found( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """FileNotFoundError when the service file doesn't exist.""" missing = str(tmp_path / "no_such_file.py") with pytest.raises(FileNotFoundError, match="Service file not found"): capturer.capture(missing) - def test_capture_non_python_file(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_non_python_file( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """ValueError when the file is not a .py file.""" txt_file = tmp_path / "service.txt" txt_file.write_text("not python") with pytest.raises(ValueError, match="must be a Python file"): capturer.capture(str(txt_file)) - def test_capture_non_python_file_js(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_non_python_file_js( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """ValueError for .js files.""" js_file = tmp_path / "service.js" js_file.write_text("console.log('hi')") with pytest.raises(ValueError, match="must be a Python file"): capturer.capture(str(js_file)) - def test_capture_dependencies_not_available(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_dependencies_not_available( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """ImportError when DEPENDENCIES_AVAILABLE is False.""" py_file = tmp_path / "service.py" py_file.write_text("pass") - with patch("signalwire.cli.core.service_loader.DEPENDENCIES_AVAILABLE", False): - with pytest.raises(ImportError, match="Required dependencies not available"): - capturer.capture(str(py_file)) + with ( + patch("signalwire.cli.core.service_loader.DEPENDENCIES_AVAILABLE", False), + pytest.raises(ImportError, match="Required dependencies not available"), + ): + capturer.capture(str(py_file)) - def test_capture_import_error_no_services_captured(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_import_error_no_services_captured( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """ImportError when module exec fails and no services were captured.""" py_file = tmp_path / "bad_service.py" py_file.write_text("raise RuntimeError('import boom')") with pytest.raises(ImportError, match="Failed to load service module"): capturer.capture(str(py_file)) - def test_capture_import_error_with_services_captured(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_import_error_with_services_captured( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """When module exec fails but services were already captured, no error raised.""" py_file = tmp_path / "partial_service.py" # Write a service file that will create an instance, call run(), then crash @@ -162,10 +180,13 @@ def test_capture_import_error_with_services_captured(self, capturer: ServiceCapt # ServiceCapture.capture Successful Loading Tests # ============================================================================= + class TestServiceCaptureSuccess: """Tests for successful service capture scenarios.""" - def test_capture_service_via_serve(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_service_via_serve( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """Capture a service that calls serve().""" py_file = tmp_path / "my_service.py" py_file.write_text( @@ -177,7 +198,9 @@ def test_capture_service_via_serve(self, capturer: ServiceCapture, tmp_path: Pat assert len(services) == 1 assert services[0].name == "serve_test" - def test_capture_service_via_run(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_service_via_run( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """Capture a service that calls run() (via AgentBase).""" py_file = tmp_path / "my_agent.py" py_file.write_text( @@ -188,7 +211,9 @@ def test_capture_service_via_run(self, capturer: ServiceCapture, tmp_path: Path) services = capturer.capture(str(py_file)) assert len(services) == 1 - def test_capture_multiple_services(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_multiple_services( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """Capture multiple services from one file.""" py_file = tmp_path / "multi_service.py" py_file.write_text( @@ -203,7 +228,9 @@ def test_capture_multiple_services(self, capturer: ServiceCapture, tmp_path: Pat names = {s.name for s in services} assert names == {"svc1", "svc2"} - def test_capture_resets_list_between_calls(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_resets_list_between_calls( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """captured_services is reset before each capture call.""" py_file = tmp_path / "resettable.py" py_file.write_text( @@ -219,7 +246,9 @@ def test_capture_resets_list_between_calls(self, capturer: ServiceCapture, tmp_p # Should be a fresh list, not appended assert len(capturer.captured_services) == 1 - def test_capture_with_suppress_output(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_with_suppress_output( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """suppress_output=True suppresses stdout from the loaded module.""" py_file = tmp_path / "noisy_service.py" py_file.write_text( @@ -229,8 +258,10 @@ def test_capture_with_suppress_output(self, capturer: ServiceCapture, tmp_path: "svc.serve()\n" ) import io + captured_output = io.StringIO() import contextlib + with contextlib.redirect_stdout(captured_output): services = capturer.capture(str(py_file), suppress_output=True) @@ -239,7 +270,12 @@ def test_capture_with_suppress_output(self, capturer: ServiceCapture, tmp_path: # suppress_output redirects inside capture() # (stdout was already redirected by capture, so our outer redirect sees nothing) - def test_capture_without_suppress_output(self, capturer: ServiceCapture, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + def test_capture_without_suppress_output( + self, + capturer: ServiceCapture, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: """suppress_output=False (default) allows stdout from the loaded module.""" py_file = tmp_path / "chatty_service.py" py_file.write_text( @@ -250,7 +286,9 @@ def test_capture_without_suppress_output(self, capturer: ServiceCapture, tmp_pat services = capturer.capture(str(py_file), suppress_output=False) assert len(services) == 1 - def test_capture_returns_list(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_returns_list( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """capture() returns a list.""" py_file = tmp_path / "list_service.py" py_file.write_text( @@ -261,7 +299,9 @@ def test_capture_returns_list(self, capturer: ServiceCapture, tmp_path: Path) -> result = capturer.capture(str(py_file)) assert isinstance(result, list) - def test_capture_resolves_relative_path(self, capturer: ServiceCapture, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_capture_resolves_relative_path( + self, capturer: ServiceCapture, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """capture() resolves the path before checking existence.""" py_file = tmp_path / "relative_service.py" py_file.write_text( @@ -273,7 +313,9 @@ def test_capture_resolves_relative_path(self, capturer: ServiceCapture, tmp_path services = capturer.capture("relative_service.py") assert len(services) == 1 - def test_capture_empty_module_returns_empty_list(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_capture_empty_module_returns_empty_list( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """Capture of a file with no services returns empty list.""" py_file = tmp_path / "empty_service.py" py_file.write_text("x = 42\n") @@ -285,6 +327,7 @@ def test_capture_empty_module_returns_empty_list(self, capturer: ServiceCapture, # ServiceCapture._apply_patches and _restore_patches Tests # ============================================================================= + class TestServiceCapturePatching: """Tests for the patch/restore mechanism.""" @@ -295,8 +338,8 @@ def test_apply_patches_stores_originals(self, capturer: ServiceCapture) -> None: try: assert len(capturer.original_methods) > 0 # Check that original serve was stored - assert (SWMLService, 'serve') in capturer.original_methods - assert capturer.original_methods[(SWMLService, 'serve')] is original_serve + assert (SWMLService, "serve") in capturer.original_methods + assert capturer.original_methods[(SWMLService, "serve")] is original_serve finally: capturer._restore_patches() @@ -309,7 +352,9 @@ def test_apply_patches_replaces_methods(self, capturer: ServiceCapture) -> None: finally: capturer._restore_patches() - def test_restore_patches_restores_original_methods(self, capturer: ServiceCapture) -> None: + def test_restore_patches_restores_original_methods( + self, capturer: ServiceCapture + ) -> None: """_restore_patches restores original methods.""" original_serve = SWMLService.serve capturer._apply_patches() @@ -327,7 +372,9 @@ def test_restore_patches_with_empty_dict(self, capturer: ServiceCapture) -> None capturer._restore_patches() # Should not raise assert capturer.original_methods == {} - def test_patches_restored_after_error(self, capturer: ServiceCapture, tmp_path: Path) -> None: + def test_patches_restored_after_error( + self, capturer: ServiceCapture, tmp_path: Path + ) -> None: """Patches are restored even when capture raises an error.""" original_serve = SWMLService.serve py_file = tmp_path / "error_service.py" @@ -341,7 +388,9 @@ def test_mock_run_captures_service(self, capturer: ServiceCapture) -> None: """The patched run() method captures the service instance.""" capturer._apply_patches() try: - svc = SWMLService(name="mock_run_test", route="/mr", schema_validation=False) + svc = SWMLService( + name="mock_run_test", route="/mr", schema_validation=False + ) # Calling run on the instance should capture it # But run is on WebMixin/AgentBase; for SWMLService only serve exists # Let's test serve: @@ -365,6 +414,7 @@ def test_mock_serve_returns_service(self, capturer: ServiceCapture) -> None: # load_and_simulate_service Tests # ============================================================================= + class TestLoadAndSimulateService: """Tests for the load_and_simulate_service function.""" @@ -380,27 +430,42 @@ def test_multiple_services_no_route_raises_value_error(self) -> None: mock_svc1 = _make_mock_service(route="/a") mock_svc2 = _make_mock_service(route="/b") - with patch.object(ServiceCapture, 'capture', return_value=[mock_svc1, mock_svc2]): - with pytest.raises(ValueError, match="Multiple services found"): - load_and_simulate_service("fake.py") + with ( + patch.object( + ServiceCapture, "capture", return_value=[mock_svc1, mock_svc2] + ), + pytest.raises(ValueError, match="Multiple services found"), + ): + load_and_simulate_service("fake.py") def test_multiple_services_wrong_route_raises_value_error(self) -> None: """ValueError when specified route doesn't match any service.""" mock_svc1 = _make_mock_service(route="/a") mock_svc2 = _make_mock_service(route="/b") - with patch.object(ServiceCapture, 'capture', return_value=[mock_svc1, mock_svc2]): - with pytest.raises(ValueError, match="No service found for route '/c'"): - load_and_simulate_service("fake.py", route="/c") + with ( + patch.object( + ServiceCapture, "capture", return_value=[mock_svc1, mock_svc2] + ), + pytest.raises(ValueError, match="No service found for route '/c'"), + ): + load_and_simulate_service("fake.py", route="/c") def test_multiple_services_correct_route_selects_service(self) -> None: """Correct service selected when route matches.""" mock_svc1 = _make_mock_service(route="/a") mock_svc2 = _make_mock_service(route="/b") - with patch.object(ServiceCapture, 'capture', return_value=[mock_svc1, mock_svc2]), \ - patch("signalwire.cli.core.service_loader.simulate_request_to_service", new=MagicMock()), \ - patch("signalwire.cli.core.service_loader.asyncio") as mock_asyncio: + with ( + patch.object( + ServiceCapture, "capture", return_value=[mock_svc1, mock_svc2] + ), + patch( + "signalwire.cli.core.service_loader.simulate_request_to_service", + new=MagicMock(), + ), + patch("signalwire.cli.core.service_loader.asyncio") as mock_asyncio, + ): mock_asyncio.run.return_value = {"result": "ok"} result = load_and_simulate_service("fake.py", route="/b") assert result == {"result": "ok"} @@ -412,9 +477,14 @@ def test_single_service_selected_automatically(self) -> None: """Single service is selected without needing a route.""" mock_svc = _make_mock_service(route="/only") - with patch.object(ServiceCapture, 'capture', return_value=[mock_svc]), \ - patch("signalwire.cli.core.service_loader.simulate_request_to_service", new=MagicMock()), \ - patch("signalwire.cli.core.service_loader.asyncio") as mock_asyncio: + with ( + patch.object(ServiceCapture, "capture", return_value=[mock_svc]), + patch( + "signalwire.cli.core.service_loader.simulate_request_to_service", + new=MagicMock(), + ), + patch("signalwire.cli.core.service_loader.asyncio") as mock_asyncio, + ): mock_asyncio.run.return_value = {"response": "data"} result = load_and_simulate_service("fake.py") assert result == {"response": "data"} @@ -423,9 +493,14 @@ def test_passes_parameters_through(self) -> None: """Method, body, query_params, and headers are forwarded.""" mock_svc = _make_mock_service(route="/only") - with patch.object(ServiceCapture, 'capture', return_value=[mock_svc]), \ - patch("signalwire.cli.core.service_loader.simulate_request_to_service", new=MagicMock()), \ - patch("signalwire.cli.core.service_loader.asyncio") as mock_asyncio: + with ( + patch.object(ServiceCapture, "capture", return_value=[mock_svc]), + patch( + "signalwire.cli.core.service_loader.simulate_request_to_service", + new=MagicMock(), + ), + patch("signalwire.cli.core.service_loader.asyncio") as mock_asyncio, + ): mock_asyncio.run.return_value = {} load_and_simulate_service( "fake.py", @@ -433,7 +508,7 @@ def test_passes_parameters_through(self) -> None: body={"key": "val"}, query_params={"q": "search"}, headers={"X-Custom": "header"}, - suppress_output=True + suppress_output=True, ) # Verify asyncio.run was called assert mock_asyncio.run.called @@ -443,7 +518,9 @@ def test_multiple_services_available_routes_in_error(self) -> None: mock_svc1 = _make_mock_service(route="/route1") mock_svc2 = _make_mock_service(route="/route2") - with patch.object(ServiceCapture, 'capture', return_value=[mock_svc1, mock_svc2]): + with patch.object( + ServiceCapture, "capture", return_value=[mock_svc1, mock_svc2] + ): with pytest.raises(ValueError, match="/route1") as exc_info: load_and_simulate_service("fake.py") assert "/route2" in str(exc_info.value) @@ -453,6 +530,7 @@ def test_multiple_services_available_routes_in_error(self) -> None: # load_agent_from_file Tests # ============================================================================= + class TestLoadAgentFromFile: """Tests for the load_agent_from_file backward compatibility function.""" @@ -460,15 +538,17 @@ def test_no_agents_raises_value_error(self) -> None: """ValueError when no agents found in the file.""" mock_svc = _make_mock_service() # Not an AgentBase - with patch.object(ServiceCapture, 'capture', return_value=[mock_svc]): - with pytest.raises(ValueError, match="No agents found"): - load_agent_from_file("fake.py") + with ( + patch.object(ServiceCapture, "capture", return_value=[mock_svc]), + pytest.raises(ValueError, match="No agents found"), + ): + load_agent_from_file("fake.py") def test_single_agent_returned(self) -> None: """Single agent is returned directly.""" mock_agent = _make_mock_agent(name="solo") - with patch.object(ServiceCapture, 'capture', return_value=[mock_agent]): + with patch.object(ServiceCapture, "capture", return_value=[mock_agent]): result = load_agent_from_file("fake.py") assert result is mock_agent @@ -477,7 +557,7 @@ def test_multiple_agents_returns_first(self) -> None: agent1 = _make_mock_agent(name="first", class_name="FirstAgent") agent2 = _make_mock_agent(name="second", class_name="SecondAgent") - with patch.object(ServiceCapture, 'capture', return_value=[agent1, agent2]): + with patch.object(ServiceCapture, "capture", return_value=[agent1, agent2]): result = load_agent_from_file("fake.py") assert result is agent1 @@ -486,7 +566,7 @@ def test_multiple_agents_with_class_name(self) -> None: agent1 = _make_mock_agent(name="first", class_name="FirstAgent") agent2 = _make_mock_agent(name="second", class_name="SecondAgent") - with patch.object(ServiceCapture, 'capture', return_value=[agent1, agent2]): + with patch.object(ServiceCapture, "capture", return_value=[agent1, agent2]): result = load_agent_from_file("fake.py", agent_class_name="SecondAgent") assert result is agent2 @@ -495,7 +575,7 @@ def test_class_name_no_match_returns_first(self) -> None: agent1 = _make_mock_agent(name="first", class_name="FirstAgent") agent2 = _make_mock_agent(name="second", class_name="SecondAgent") - with patch.object(ServiceCapture, 'capture', return_value=[agent1, agent2]): + with patch.object(ServiceCapture, "capture", return_value=[agent1, agent2]): result = load_agent_from_file("fake.py", agent_class_name="ThirdAgent") assert result is agent1 @@ -504,7 +584,9 @@ def test_filters_non_agents(self) -> None: mock_svc = _make_mock_service() mock_agent = _make_mock_agent(name="real_agent") - with patch.object(ServiceCapture, 'capture', return_value=[mock_svc, mock_agent]): + with patch.object( + ServiceCapture, "capture", return_value=[mock_svc, mock_agent] + ): result = load_agent_from_file("fake.py") assert result is mock_agent @@ -512,35 +594,42 @@ def test_suppress_output_forwarded(self) -> None: """suppress_output parameter is forwarded to capture().""" mock_agent = _make_mock_agent() - with patch.object(ServiceCapture, 'capture', return_value=[mock_agent]) as mock_capture: + with patch.object( + ServiceCapture, "capture", return_value=[mock_agent] + ) as mock_capture: load_agent_from_file("fake.py", suppress_output=True) mock_capture.assert_called_once_with("fake.py", suppress_output=True) def test_empty_capture_raises(self) -> None: """Empty capture list raises ValueError (no agents).""" - with patch.object(ServiceCapture, 'capture', return_value=[]): - with pytest.raises(ValueError, match="No agents found"): - load_agent_from_file("fake.py") + with ( + patch.object(ServiceCapture, "capture", return_value=[]), + pytest.raises(ValueError, match="No agents found"), + ): + load_agent_from_file("fake.py") # ============================================================================= # discover_agents_in_file Tests # ============================================================================= + class TestDiscoverAgentsInFile: """Tests for the discover_agents_in_file backward compatibility function.""" def test_empty_file_returns_empty_list(self) -> None: """Empty capture returns empty list.""" - with patch.object(ServiceCapture, 'capture', return_value=[]): + with patch.object(ServiceCapture, "capture", return_value=[]): result = discover_agents_in_file("fake.py") assert result == [] def test_returns_proper_dict_format(self) -> None: """Each entry has expected keys: name, class_name, type, agent_name, route, description, object.""" - mock_agent = _make_mock_agent(name="discover_me", route="/d", class_name="DiscoverAgent") + mock_agent = _make_mock_agent( + name="discover_me", route="/d", class_name="DiscoverAgent" + ) - with patch.object(ServiceCapture, 'capture', return_value=[mock_agent]): + with patch.object(ServiceCapture, "capture", return_value=[mock_agent]): result = discover_agents_in_file("fake.py") assert len(result) == 1 entry = result[0] @@ -556,7 +645,9 @@ def test_filters_non_agent_services(self) -> None: mock_svc = _make_mock_service() mock_agent = _make_mock_agent(name="agent_only") - with patch.object(ServiceCapture, 'capture', return_value=[mock_svc, mock_agent]): + with patch.object( + ServiceCapture, "capture", return_value=[mock_svc, mock_agent] + ): result = discover_agents_in_file("fake.py") assert len(result) == 1 assert result[0]["name"] == "agent_only" @@ -566,7 +657,7 @@ def test_multiple_agents(self) -> None: agent1 = _make_mock_agent(name="agent1", class_name="Agent1") agent2 = _make_mock_agent(name="agent2", class_name="Agent2") - with patch.object(ServiceCapture, 'capture', return_value=[agent1, agent2]): + with patch.object(ServiceCapture, "capture", return_value=[agent1, agent2]): result = discover_agents_in_file("fake.py") assert len(result) == 2 names = {e["name"] for e in result} @@ -577,7 +668,7 @@ def test_description_from_docstring(self) -> None: mock_agent = _make_mock_agent(name="documented") # The docstring is set in _make_mock_agent via the type() call - with patch.object(ServiceCapture, 'capture', return_value=[mock_agent]): + with patch.object(ServiceCapture, "capture", return_value=[mock_agent]): result = discover_agents_in_file("fake.py") assert result[0]["description"] == "A mock agent" @@ -586,6 +677,7 @@ def test_description_from_docstring(self) -> None: # simulate_request_to_service Tests # ============================================================================= + class TestSimulateRequestToService: """Tests for the async simulate_request_to_service function.""" @@ -599,17 +691,24 @@ async def async_handler(request: Any, response: Any) -> dict[str, Any]: mock_service._handle_request = async_handler - with patch("signalwire.cli.simulation.mock_env.create_mock_request") as mock_create, \ - patch("signalwire.cli.core.service_loader.Response") as mock_response_cls: + with ( + patch( + "signalwire.cli.simulation.mock_env.create_mock_request" + ) as mock_create, + patch("signalwire.cli.core.service_loader.Response") as mock_response_cls, + ): mock_create.return_value = Mock() mock_response_cls.return_value = Mock() - result = await simulate_request_to_service(mock_service, body={"test": True}) + result = await simulate_request_to_service( + mock_service, body={"test": True} + ) assert result == {"swml": "data"} @pytest.mark.asyncio async def test_simulate_returns_body_response(self) -> None: """When the handler returns an object with body attr, parse as JSON.""" import json as json_mod + response_obj = Mock() response_obj.body = json_mod.dumps({"parsed": True}).encode() @@ -620,8 +719,12 @@ async def async_handler(request: Any, response: Any) -> Any: mock_service._handle_request = async_handler - with patch("signalwire.cli.simulation.mock_env.create_mock_request") as mock_create, \ - patch("signalwire.cli.core.service_loader.Response") as mock_response_cls: + with ( + patch( + "signalwire.cli.simulation.mock_env.create_mock_request" + ) as mock_create, + patch("signalwire.cli.core.service_loader.Response") as mock_response_cls, + ): mock_create.return_value = Mock() mock_response_cls.return_value = Mock() result = await simulate_request_to_service(mock_service) @@ -637,8 +740,12 @@ async def async_handler(request: Any, response: Any) -> str: mock_service._handle_request = async_handler - with patch("signalwire.cli.simulation.mock_env.create_mock_request") as mock_create, \ - patch("signalwire.cli.core.service_loader.Response") as mock_response_cls: + with ( + patch( + "signalwire.cli.simulation.mock_env.create_mock_request" + ) as mock_create, + patch("signalwire.cli.core.service_loader.Response") as mock_response_cls, + ): mock_create.return_value = Mock() mock_response_cls.return_value = Mock() result = await simulate_request_to_service(mock_service) diff --git a/tests/unit/cli/test_swaig_parse_only.py b/tests/unit/cli/test_swaig_parse_only.py index e82aebd2..a5a502f7 100644 --- a/tests/unit/cli/test_swaig_parse_only.py +++ b/tests/unit/cli/test_swaig_parse_only.py @@ -23,11 +23,11 @@ invocation naming a non-existent file still reports ``parse OK``. """ -import sys # noqa: E402 +import sys -import pytest # noqa: E402 +import pytest -from signalwire.cli.test_swaig import main # noqa: E402 +from signalwire.cli.test_swaig import main def _run(argv: list[str], monkeypatch: pytest.MonkeyPatch) -> int: diff --git a/tests/unit/core/agent/tools/test_tool_registry.py b/tests/unit/core/agent/tools/test_tool_registry.py index 97d0fbf7..afb7d3f3 100644 --- a/tests/unit/core/agent/tools/test_tool_registry.py +++ b/tests/unit/core/agent/tools/test_tool_registry.py @@ -24,7 +24,9 @@ def registry() -> ToolRegistry: class TestToolRegistryDefineAndQuery: def test_register_swaig_function_via_dict(self, registry: ToolRegistry) -> None: - registry.register_swaig_function({"function": "lookup", "description": "Look up a value"}) + registry.register_swaig_function( + {"function": "lookup", "description": "Look up a value"} + ) assert registry.has_function("lookup") def test_has_function_false_when_unregistered(self, registry: ToolRegistry) -> None: @@ -61,12 +63,17 @@ def test_remove_function_when_present(self, registry: ToolRegistry) -> None: assert registry.remove_function("doomed") is True assert registry.has_function("doomed") is False - def test_remove_function_when_absent_returns_false(self, registry: ToolRegistry) -> None: + def test_remove_function_when_absent_returns_false( + self, registry: ToolRegistry + ) -> None: assert registry.remove_function("never_existed") is False def test_define_tool_registers_with_handler(self, registry: ToolRegistry) -> None: - def my_handler(args: dict[str, Any], raw_data: dict[str, Any] | None = None) -> dict[str, str]: + def my_handler( + args: dict[str, Any], raw_data: dict[str, Any] | None = None + ) -> dict[str, str]: return {"result": "ok"} + registry.define_tool( name="echo", description="Echo back", diff --git a/tests/unit/core/agent/tools/test_type_inference.py b/tests/unit/core/agent/tools/test_type_inference.py index fefcc7ad..c675e700 100644 --- a/tests/unit/core/agent/tools/test_type_inference.py +++ b/tests/unit/core/agent/tools/test_type_inference.py @@ -11,7 +11,7 @@ Comprehensive tests for type-hint-based tool schema inference. """ -from typing import Any, Optional, List, Dict, Literal +from typing import Any, Optional, Literal from signalwire.core.agent.tools.type_inference import ( infer_schema, @@ -28,6 +28,7 @@ # Tests for _resolve_type # =========================================================================== + class TestResolveType: """Tests for the _resolve_type helper.""" @@ -82,23 +83,24 @@ def test_literal_ints(self) -> None: assert optional is False def test_list_of_str(self) -> None: - schema, optional = _resolve_type(List[str]) + schema, optional = _resolve_type(list[str]) assert schema == {"type": "array", "items": {"type": "string"}} assert optional is False def test_list_of_int(self) -> None: - schema, optional = _resolve_type(List[int]) + schema, optional = _resolve_type(list[int]) assert schema == {"type": "array", "items": {"type": "integer"}} assert optional is False def test_dict_str_any(self) -> None: - schema, optional = _resolve_type(Dict[str, int]) + schema, optional = _resolve_type(dict[str, int]) assert schema == {"type": "object"} assert optional is False def test_unknown_type_falls_back_to_string(self) -> None: class CustomType: pass + schema, optional = _resolve_type(CustomType) assert schema == {"type": "string"} assert optional is False @@ -108,6 +110,7 @@ class CustomType: # Tests for _parse_docstring_args # =========================================================================== + class TestParseDocstringArgs: """Tests for docstring parsing.""" @@ -160,7 +163,7 @@ def test_args_block_with_returns_section(self) -> None: Returns: Some result """ - summary, params = _parse_docstring_args(doc) + _summary, params = _parse_docstring_args(doc) assert params["x"] == "First param" assert params["y"] == "Second param" @@ -172,7 +175,7 @@ def test_multiline_param_description(self) -> None: against the database limit: Maximum results """ - summary, params = _parse_docstring_args(doc) + _summary, params = _parse_docstring_args(doc) assert "search query to execute" in params["query"] assert "against the database" in params["query"] assert params["limit"] == "Maximum results" @@ -182,50 +185,63 @@ def test_multiline_param_description(self) -> None: # Tests for infer_schema - detection heuristic # =========================================================================== + class TestInferSchemaDetection: """Tests for when infer_schema should and should not activate.""" def test_old_style_args_raw_data(self) -> None: """Old-style (args, raw_data) should not be treated as typed.""" + def handler(args: dict[str, Any], raw_data: dict[str, Any]) -> None: pass - params, required, desc, is_typed, has_raw_data = infer_schema(handler) + + _params, _required, _desc, is_typed, _has_raw_data = infer_schema(handler) assert is_typed is False def test_old_style_args_only(self) -> None: """Old-style (args,) should not be treated as typed.""" + def handler(args: dict[str, Any]) -> None: pass - params, required, desc, is_typed, has_raw_data = infer_schema(handler) + + _params, _required, _desc, is_typed, _has_raw_data = infer_schema(handler) assert is_typed is False def test_varargs_fallback(self) -> None: """Functions with *args should fall back to old style.""" + def handler(*args: Any) -> None: pass - params, required, desc, is_typed, has_raw_data = infer_schema(handler) + + _params, _required, _desc, is_typed, _has_raw_data = infer_schema(handler) assert is_typed is False def test_kwargs_fallback(self) -> None: """Functions with **kwargs should fall back to old style.""" + def handler(**kwargs: Any) -> None: pass - params, required, desc, is_typed, has_raw_data = infer_schema(handler) + + _params, _required, _desc, is_typed, _has_raw_data = infer_schema(handler) assert is_typed is False def test_typed_params_detected(self) -> None: """Typed parameters should be detected as new style.""" + def handler(city: str, units: str = "celsius") -> None: pass - params, required, desc, is_typed, has_raw_data = infer_schema(handler) + + _params, _required, _desc, is_typed, _has_raw_data = infer_schema(handler) assert is_typed is True def test_zero_param_tool(self) -> None: """Function with no params (after self filtering) is a valid zero-param typed tool.""" + def handler() -> None: """Get the current time.""" pass - params, required, desc, is_typed, has_raw_data = infer_schema(handler) + + params, required, desc, is_typed, _has_raw_data = infer_schema(handler) assert is_typed is True assert params == {} assert required == [] @@ -233,18 +249,22 @@ def handler() -> None: def test_self_filtered_out(self) -> None: """self parameter should be filtered out.""" + def handler(self: Any, city: str) -> None: pass - params, required, desc, is_typed, has_raw_data = infer_schema(handler) + + params, _required, _desc, is_typed, _has_raw_data = infer_schema(handler) assert is_typed is True assert "self" not in params assert "city" in params def test_no_annotations_fallback(self) -> None: """No type hints on non-standard param names should fall back.""" + def handler(city, units) -> None: # type: ignore[no-untyped-def] # intentional: exercises the no-annotations fallback path pass - params, required, desc, is_typed, has_raw_data = infer_schema(handler) + + _params, _required, _desc, is_typed, _has_raw_data = infer_schema(handler) assert is_typed is False @@ -252,48 +272,56 @@ def handler(city, units) -> None: # type: ignore[no-untyped-def] # intentional # Tests for infer_schema - type mapping # =========================================================================== + class TestInferSchemaTypes: """Tests for correct type mapping in inferred schemas.""" def test_string_param(self) -> None: def handler(name: str) -> None: pass + params, *_ = infer_schema(handler) assert params["name"]["type"] == "string" def test_int_param(self) -> None: def handler(count: int) -> None: pass + params, *_ = infer_schema(handler) assert params["count"]["type"] == "integer" def test_float_param(self) -> None: def handler(price: float) -> None: pass + params, *_ = infer_schema(handler) assert params["price"]["type"] == "number" def test_bool_param(self) -> None: def handler(enabled: bool) -> None: pass + params, *_ = infer_schema(handler) assert params["enabled"]["type"] == "boolean" def test_list_param(self) -> None: def handler(items: list[Any]) -> None: pass + params, *_ = infer_schema(handler) assert params["items"]["type"] == "array" def test_dict_param(self) -> None: def handler(data: dict[str, Any]) -> None: pass + params, *_ = infer_schema(handler) assert params["data"]["type"] == "object" def test_optional_param(self) -> None: - def handler(name: str, nickname: Optional[str] = None) -> None: + def handler(name: str, nickname: str | None = None) -> None: pass + params, required, *_ = infer_schema(handler) assert params["name"]["type"] == "string" assert params["nickname"]["type"] == "string" @@ -303,13 +331,15 @@ def handler(name: str, nickname: Optional[str] = None) -> None: def test_literal_param(self) -> None: def handler(color: Literal["red", "green", "blue"]) -> None: pass + params, *_ = infer_schema(handler) assert params["color"]["type"] == "string" assert params["color"]["enum"] == ["red", "green", "blue"] def test_list_of_str_param(self) -> None: - def handler(tags: List[str]) -> None: + def handler(tags: list[str]) -> None: pass + params, *_ = infer_schema(handler) assert params["tags"]["type"] == "array" assert params["tags"]["items"] == {"type": "string"} @@ -319,30 +349,37 @@ def handler(tags: List[str]) -> None: # Tests for infer_schema - required/optional # =========================================================================== + class TestInferSchemaRequired: """Tests for required vs optional parameter detection.""" def test_no_default_is_required(self) -> None: def handler(city: str) -> None: pass + _, required, *_ = infer_schema(handler) assert "city" in required def test_with_default_is_optional(self) -> None: def handler(city: str = "London") -> None: pass + _, required, *_ = infer_schema(handler) assert "city" not in required def test_optional_type_is_not_required(self) -> None: - def handler(city: Optional[str]) -> None: + def handler(city: str | None) -> None: pass + _, required, *_ = infer_schema(handler) assert "city" not in required def test_mixed_required_optional(self) -> None: - def handler(city: str, units: str = "celsius", country: Optional[str] = None) -> None: + def handler( + city: str, units: str = "celsius", country: str | None = None + ) -> None: pass + _, required, *_ = infer_schema(handler) assert "city" in required assert "units" not in required @@ -353,6 +390,7 @@ def handler(city: str, units: str = "celsius", country: Optional[str] = None) -> # Tests for infer_schema - docstring integration # =========================================================================== + class TestInferSchemaDocstring: """Tests for docstring-driven description and parameter docs.""" @@ -360,12 +398,14 @@ def test_description_from_docstring(self) -> None: def handler(city: str) -> None: """Get the weather forecast.""" pass + _, _, desc, *_ = infer_schema(handler) assert desc == "Get the weather forecast." def test_no_docstring(self) -> None: def handler(city: str) -> None: pass + _, _, desc, *_ = infer_schema(handler) assert desc is None @@ -378,6 +418,7 @@ def handler(city: str, units: str = "celsius") -> None: units: Temperature units """ pass + params, *_ = infer_schema(handler) assert params["city"]["description"] == "Name of the city" assert params["units"]["description"] == "Temperature units" @@ -387,19 +428,22 @@ def handler(city: str, units: str = "celsius") -> None: # Tests for infer_schema - raw_data handling # =========================================================================== + class TestInferSchemaRawData: """Tests for raw_data parameter detection and exclusion.""" def test_raw_data_detected(self) -> None: - def handler(city: str, raw_data: Optional[dict[str, Any]] = None) -> None: + def handler(city: str, raw_data: dict[str, Any] | None = None) -> None: pass + _, _, _, is_typed, has_raw_data = infer_schema(handler) assert is_typed is True assert has_raw_data is True def test_raw_data_excluded_from_schema(self) -> None: - def handler(city: str, raw_data: Optional[dict[str, Any]] = None) -> None: + def handler(city: str, raw_data: dict[str, Any] | None = None) -> None: pass + params, *_ = infer_schema(handler) assert "raw_data" not in params assert "city" in params @@ -407,14 +451,17 @@ def handler(city: str, raw_data: Optional[dict[str, Any]] = None) -> None: def test_no_raw_data(self) -> None: def handler(city: str) -> None: pass + _, _, _, is_typed, has_raw_data = infer_schema(handler) assert is_typed is True assert has_raw_data is False def test_only_raw_data(self) -> None: """Function with only raw_data param is a zero-param typed tool with raw_data.""" + def handler(raw_data: dict[str, Any]) -> None: pass + params, required, _, is_typed, has_raw_data = infer_schema(handler) assert is_typed is True assert has_raw_data is True @@ -426,6 +473,7 @@ def handler(raw_data: dict[str, Any]) -> None: # Tests for create_typed_handler_wrapper # =========================================================================== + class TestCreateTypedHandlerWrapper: """Tests for the handler wrapper function.""" @@ -440,7 +488,9 @@ def handler(city: str, units: str = "celsius") -> FunctionResult: assert "fahrenheit" in result.response def test_wrapper_passes_raw_data(self) -> None: - def handler(city: str, raw_data: Optional[dict[str, Any]] = None) -> FunctionResult: + def handler( + city: str, raw_data: dict[str, Any] | None = None + ) -> FunctionResult: call_id = raw_data.get("call_id", "none") if raw_data else "none" return FunctionResult(f"Weather in {city}, call={call_id}") @@ -505,6 +555,7 @@ def handler(city: str, units: str = "celsius") -> FunctionResult: # Tests for end-to-end integration with AgentBase # =========================================================================== + class TestEndToEndIntegration: """Tests that type inference works end-to-end through the decorator and registry.""" @@ -549,7 +600,11 @@ class TestAgent(AgentBase): def __init__(self) -> None: super().__init__("Test Agent", route="/test2") - @AgentBase.tool(name="get_weather2", parameters=explicit_params, description="Explicit desc") + @AgentBase.tool( + name="get_weather2", + parameters=explicit_params, + description="Explicit desc", + ) def get_weather(self, city: str, units: str = "celsius") -> FunctionResult: """Get the weather forecast.""" return FunctionResult(f"Weather in {city}") @@ -601,7 +656,9 @@ def greet(self, name: str, greeting: str = "Hello") -> FunctionResult: return FunctionResult(f"{greeting}, {name}!") agent = TestAgent() - result = agent.on_function_call("greet", {"name": "Alice", "greeting": "Hi"}, {}) + result = agent.on_function_call( + "greet", {"name": "Alice", "greeting": "Hi"}, {} + ) assert isinstance(result, FunctionResult) assert "Hi" in result.response assert "Alice" in result.response @@ -632,7 +689,9 @@ def __init__(self) -> None: super().__init__("Test Agent", route="/test6") @AgentBase.tool(name="check_call") - def check_call(self, query: str, raw_data: Optional[dict[str, Any]] = None) -> FunctionResult: + def check_call( + self, query: str, raw_data: dict[str, Any] | None = None + ) -> FunctionResult: """Check the call. Args: @@ -642,7 +701,9 @@ def check_call(self, query: str, raw_data: Optional[dict[str, Any]] = None) -> F return FunctionResult(f"query={query}, call={call_id}") agent = TestAgent() - result = agent.on_function_call("check_call", {"query": "test"}, {"call_id": "c42"}) + result = agent.on_function_call( + "check_call", {"query": "test"}, {"call_id": "c42"} + ) assert isinstance(result, FunctionResult) assert "test" in result.response assert "c42" in result.response @@ -655,7 +716,9 @@ def __init__(self) -> None: super().__init__("Test Agent", route="/test7") @AgentBase.tool(name="set_mode") - def set_mode(self, mode: Literal["auto", "manual", "off"]) -> FunctionResult: + def set_mode( + self, mode: Literal["auto", "manual", "off"] + ) -> FunctionResult: """Set the operating mode. Args: diff --git a/tests/unit/core/mixins/test_ai_config_mixin.py b/tests/unit/core/mixins/test_ai_config_mixin.py index 3dce2234..38e93dd8 100644 --- a/tests/unit/core/mixins/test_ai_config_mixin.py +++ b/tests/unit/core/mixins/test_ai_config_mixin.py @@ -49,11 +49,12 @@ def host() -> MockAIConfigHost: # Tests for add_pattern_hint (lines 65-72) # =========================================================================== + class TestAddPatternHint: """Tests for AIConfigMixin.add_pattern_hint""" def test_adds_pattern_hint_with_all_fields(self, host: MockAIConfigHost) -> None: - result = host.add_pattern_hint("SignalWire", r"signal\s*wire", "SignalWire") + host.add_pattern_hint("SignalWire", r"signal\s*wire", "SignalWire") assert len(host._hints) == 1 assert host._hints[0] == { "hint": "SignalWire", @@ -96,6 +97,7 @@ def test_multiple_pattern_hints(self, host: MockAIConfigHost) -> None: # Tests for add_language (lines 116-120, 123-132, 139-140, 143-144) # =========================================================================== + class TestAddLanguage: """Tests for AIConfigMixin.add_language""" @@ -124,7 +126,9 @@ def test_explicit_model_param(self, host: MockAIConfigHost) -> None: assert "engine" not in lang def test_explicit_engine_and_model_params(self, host: MockAIConfigHost) -> None: - host.add_language("English", "en-US", "josh", engine="elevenlabs", model="eleven_turbo_v2_5") + host.add_language( + "English", "en-US", "josh", engine="elevenlabs", model="eleven_turbo_v2_5" + ) lang = host._languages[0] assert lang["voice"] == "josh" assert lang["engine"] == "elevenlabs" @@ -137,14 +141,18 @@ def test_combined_format_engine_voice_model(self, host: MockAIConfigHost) -> Non assert lang["engine"] == "elevenlabs" assert lang["model"] == "eleven_turbo_v2_5" - def test_combined_format_parse_failure_fallback(self, host: MockAIConfigHost) -> None: + def test_combined_format_parse_failure_fallback( + self, host: MockAIConfigHost + ) -> None: """Malformed combined format (dot but no colon) uses voice as-is.""" host.add_language("English", "en-US", "some-voice-no-colon") lang = host._languages[0] assert lang["voice"] == "some-voice-no-colon" assert "engine" not in lang - def test_combined_format_with_dot_but_missing_colon(self, host: MockAIConfigHost) -> None: + def test_combined_format_with_dot_but_missing_colon( + self, host: MockAIConfigHost + ) -> None: """Voice with a dot but no colon is treated as a simple voice string.""" host.add_language("English", "en-US", "engine.voice") lang = host._languages[0] @@ -162,17 +170,22 @@ def test_combined_format_value_error_fallback(self, host: MockAIConfigHost) -> N # "nodot:model" -> split(":", 1) -> ("nodot", "model"), then "nodot".split(".", 1) -> ValueError assert lang["voice"] == "nodot:model" - def test_both_speech_fillers_and_function_fillers(self, host: MockAIConfigHost) -> None: + def test_both_speech_fillers_and_function_fillers( + self, host: MockAIConfigHost + ) -> None: speech = ["um", "uh"] func = ["let me check", "one moment"] - host.add_language("English", "en-US", "voice1", - speech_fillers=speech, function_fillers=func) + host.add_language( + "English", "en-US", "voice1", speech_fillers=speech, function_fillers=func + ) lang = host._languages[0] assert lang["speech_fillers"] == speech assert lang["function_fillers"] == func assert "fillers" not in lang - def test_only_speech_fillers_uses_deprecated_field(self, host: MockAIConfigHost) -> None: + def test_only_speech_fillers_uses_deprecated_field( + self, host: MockAIConfigHost + ) -> None: speech = ["um", "uh"] host.add_language("English", "en-US", "voice1", speech_fillers=speech) lang = host._languages[0] @@ -180,7 +193,9 @@ def test_only_speech_fillers_uses_deprecated_field(self, host: MockAIConfigHost) assert "speech_fillers" not in lang assert "function_fillers" not in lang - def test_only_function_fillers_uses_deprecated_field(self, host: MockAIConfigHost) -> None: + def test_only_function_fillers_uses_deprecated_field( + self, host: MockAIConfigHost + ) -> None: func = ["let me check"] host.add_language("English", "en-US", "voice1", function_fillers=func) lang = host._languages[0] @@ -199,7 +214,9 @@ def test_no_fillers_produces_no_filler_keys(self, host: MockAIConfigHost) -> Non assert "speech_fillers" not in lang assert "function_fillers" not in lang - def test_combined_format_colon_and_dot_both_present_but_no_dot_in_engine_part(self, host: MockAIConfigHost) -> None: + def test_combined_format_colon_and_dot_both_present_but_no_dot_in_engine_part( + self, host: MockAIConfigHost + ) -> None: """Voice like '.name:model' where engine part is empty string after split.""" host.add_language("English", "en-US", ".voice:model") lang = host._languages[0] @@ -215,19 +232,34 @@ def test_combined_format_colon_and_dot_both_present_but_no_dot_in_engine_part(se # get_language_params # =========================================================================== + class TestPerLanguageParams: """Tests for the per-language ``params`` dict support.""" - def test_add_language_with_params_attaches_params(self, host: MockAIConfigHost) -> None: - host.add_language("English", "en-US", "josh", engine="elevenlabs", - params={"stability": 0.5, "similarity_boost": 0.75}) - assert host._languages[0]["params"] == {"stability": 0.5, "similarity_boost": 0.75} + def test_add_language_with_params_attaches_params( + self, host: MockAIConfigHost + ) -> None: + host.add_language( + "English", + "en-US", + "josh", + engine="elevenlabs", + params={"stability": 0.5, "similarity_boost": 0.75}, + ) + assert host._languages[0]["params"] == { + "stability": 0.5, + "similarity_boost": 0.75, + } - def test_add_language_without_params_omits_key(self, host: MockAIConfigHost) -> None: + def test_add_language_without_params_omits_key( + self, host: MockAIConfigHost + ) -> None: host.add_language("French", "fr-FR", "fr-FR-Neural2-A") assert "params" not in host._languages[0] - def test_add_language_with_empty_params_omits_key(self, host: MockAIConfigHost) -> None: + def test_add_language_with_empty_params_omits_key( + self, host: MockAIConfigHost + ) -> None: host.add_language("French", "fr-FR", "v", params={}) assert "params" not in host._languages[0] @@ -235,14 +267,20 @@ def test_get_language_params_returns_set_dict(self, host: MockAIConfigHost) -> N host.add_language("English", "en-US", "v", params={"a": 1}) assert host.get_language_params("en-US") == {"a": 1} - def test_get_language_params_returns_none_when_unset(self, host: MockAIConfigHost) -> None: + def test_get_language_params_returns_none_when_unset( + self, host: MockAIConfigHost + ) -> None: host.add_language("English", "en-US", "v") assert host.get_language_params("en-US") is None - def test_get_language_params_returns_none_for_unknown_code(self, host: MockAIConfigHost) -> None: + def test_get_language_params_returns_none_for_unknown_code( + self, host: MockAIConfigHost + ) -> None: assert host.get_language_params("zh-CN") is None - def test_set_language_params_replaces_existing(self, host: MockAIConfigHost) -> None: + def test_set_language_params_replaces_existing( + self, host: MockAIConfigHost + ) -> None: host.add_language("English", "en-US", "v", params={"a": 1}) host.set_language_params("en-US", {"b": 2}) assert host.get_language_params("en-US") == {"b": 2} @@ -252,19 +290,25 @@ def test_set_language_params_adds_when_unset(self, host: MockAIConfigHost) -> No host.set_language_params("en-US", {"c": 3}) assert host.get_language_params("en-US") == {"c": 3} - def test_set_language_params_empty_dict_removes_key(self, host: MockAIConfigHost) -> None: + def test_set_language_params_empty_dict_removes_key( + self, host: MockAIConfigHost + ) -> None: host.add_language("English", "en-US", "v", params={"a": 1}) host.set_language_params("en-US", {}) assert host.get_language_params("en-US") is None assert "params" not in host._languages[0] - def test_set_language_params_unknown_code_is_noop(self, host: MockAIConfigHost) -> None: + def test_set_language_params_unknown_code_is_noop( + self, host: MockAIConfigHost + ) -> None: host.add_language("English", "en-US", "v") host.set_language_params("zh-CN", {"a": 1}) # The known language remains untouched. assert host._languages[0].get("params") is None - def test_set_language_params_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: + def test_set_language_params_returns_self_for_chaining( + self, host: MockAIConfigHost + ) -> None: host.add_language("English", "en-US", "v") assert host.set_language_params("en-US", {"a": 1}) is host @@ -273,12 +317,13 @@ def test_set_language_params_returns_self_for_chaining(self, host: MockAIConfigH # Tests for set_languages (lines 159-161) # =========================================================================== + class TestSetLanguages: """Tests for AIConfigMixin.set_languages""" def test_sets_languages_with_valid_list(self, host: MockAIConfigHost) -> None: langs = [{"name": "English", "code": "en-US", "voice": "voice1"}] - result = host.set_languages(langs) + host.set_languages(langs) assert host._languages is langs def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: @@ -305,6 +350,7 @@ def test_non_list_does_not_set(self, host: MockAIConfigHost) -> None: # Tests for add_pronunciation with ignore_case (line 184) # =========================================================================== + class TestAddPronunciation: """Tests for AIConfigMixin.add_pronunciation""" @@ -330,12 +376,13 @@ def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: # Tests for set_pronunciations (lines 199-201) # =========================================================================== + class TestSetPronunciations: """Tests for AIConfigMixin.set_pronunciations""" def test_sets_pronunciations_with_valid_list(self, host: MockAIConfigHost) -> None: rules = [{"replace": "SQL", "with": "sequel"}] - result = host.set_pronunciations(rules) + host.set_pronunciations(rules) assert host._pronounce is rules def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: @@ -362,12 +409,13 @@ def test_non_list_does_not_set(self, host: MockAIConfigHost) -> None: # Tests for set_global_data (lines 242-244) # =========================================================================== + class TestSetGlobalData: """Tests for AIConfigMixin.set_global_data""" def test_sets_global_data_with_valid_dict(self, host: MockAIConfigHost) -> None: data = {"key": "value", "num": 42} - result = host.set_global_data(data) + host.set_global_data(data) assert host._global_data == data def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: @@ -389,12 +437,13 @@ def test_none_does_not_set(self, host: MockAIConfigHost) -> None: # Tests for update_global_data (lines 256-258) # =========================================================================== + class TestUpdateGlobalData: """Tests for AIConfigMixin.update_global_data""" def test_updates_global_data(self, host: MockAIConfigHost) -> None: host._global_data = {"existing": "value"} - result = host.update_global_data({"new": "data"}) + host.update_global_data({"new": "data"}) assert host._global_data == {"existing": "value", "new": "data"} def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: @@ -416,11 +465,12 @@ def test_none_does_not_update(self, host: MockAIConfigHost) -> None: # Tests for set_native_functions (lines 270-272) # =========================================================================== + class TestSetNativeFunctions: """Tests for AIConfigMixin.set_native_functions""" def test_sets_native_functions(self, host: MockAIConfigHost) -> None: - result = host.set_native_functions(["check_time", "wait_for_user"]) + host.set_native_functions(["check_time", "wait_for_user"]) assert host.native_functions == ["check_time", "wait_for_user"] def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: @@ -446,21 +496,24 @@ def test_none_does_not_set(self, host: MockAIConfigHost) -> None: # Tests for set_internal_fillers (lines 300-304) # =========================================================================== + class TestSetInternalFillers: """Tests for AIConfigMixin.set_internal_fillers""" - def test_sets_internal_fillers_with_valid_dict(self, host: MockAIConfigHost) -> None: - fillers = { - "next_step": {"en-US": ["Moving on...", "Let's continue..."]} - } - result = host.set_internal_fillers(fillers) + def test_sets_internal_fillers_with_valid_dict( + self, host: MockAIConfigHost + ) -> None: + fillers = {"next_step": {"en-US": ["Moving on...", "Let's continue..."]}} + host.set_internal_fillers(fillers) assert host._internal_fillers == fillers def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.set_internal_fillers({"fn": {"en": ["filler"]}}) assert result is host - def test_creates_internal_fillers_attr_if_missing(self, host: MockAIConfigHost) -> None: + def test_creates_internal_fillers_attr_if_missing( + self, host: MockAIConfigHost + ) -> None: del host._internal_fillers host.set_internal_fillers({"fn": {"en": ["filler"]}}) assert host._internal_fillers == {"fn": {"en": ["filler"]}} @@ -491,18 +544,21 @@ def test_non_dict_does_not_set(self, host: MockAIConfigHost) -> None: # Tests for add_internal_filler (lines 321-329) # =========================================================================== + class TestAddInternalFiller: """Tests for AIConfigMixin.add_internal_filler""" def test_adds_filler_for_new_function(self, host: MockAIConfigHost) -> None: - result = host.add_internal_filler("next_step", "en-US", ["Moving on..."]) + host.add_internal_filler("next_step", "en-US", ["Moving on..."]) assert host._internal_fillers["next_step"]["en-US"] == ["Moving on..."] def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.add_internal_filler("fn", "en", ["filler"]) assert result is host - def test_creates_internal_fillers_attr_if_missing(self, host: MockAIConfigHost) -> None: + def test_creates_internal_fillers_attr_if_missing( + self, host: MockAIConfigHost + ) -> None: del host._internal_fillers host.add_internal_filler("fn", "en", ["filler"]) assert host._internal_fillers == {"fn": {"en": ["filler"]}} @@ -534,6 +590,7 @@ def test_none_fillers_does_not_add(self, host: MockAIConfigHost) -> None: # Tests for add_function_include with meta_data (line 349) # =========================================================================== + class TestAddFunctionInclude: """Tests for AIConfigMixin.add_function_include""" @@ -541,7 +598,7 @@ def test_adds_include_with_meta_data(self, host: MockAIConfigHost) -> None: host.add_function_include( "https://example.com/swaig", ["func1", "func2"], - meta_data={"auth_token": "abc123"} + meta_data={"auth_token": "abc123"}, ) assert len(host._function_includes) == 1 inc = host._function_includes[0] @@ -568,6 +625,7 @@ def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: # Tests for set_function_includes (lines 364-373) # =========================================================================== + class TestSetFunctionIncludes: """Tests for AIConfigMixin.set_function_includes""" @@ -576,7 +634,7 @@ def test_sets_valid_includes(self, host: MockAIConfigHost) -> None: {"url": "https://example.com", "functions": ["fn1", "fn2"]}, {"url": "https://other.com", "functions": ["fn3"]}, ] - result = host.set_function_includes(includes) + host.set_function_includes(includes) assert len(host._function_includes) == 2 assert host._function_includes[0]["url"] == "https://example.com" @@ -584,7 +642,9 @@ def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.set_function_includes([{"url": "u", "functions": ["f"]}]) assert result is host - def test_filters_out_invalid_includes_missing_url(self, host: MockAIConfigHost) -> None: + def test_filters_out_invalid_includes_missing_url( + self, host: MockAIConfigHost + ) -> None: includes = [ {"functions": ["fn1"]}, # missing url {"url": "https://valid.com", "functions": ["fn2"]}, @@ -593,7 +653,9 @@ def test_filters_out_invalid_includes_missing_url(self, host: MockAIConfigHost) assert len(host._function_includes) == 1 assert host._function_includes[0]["url"] == "https://valid.com" - def test_filters_out_invalid_includes_missing_functions(self, host: MockAIConfigHost) -> None: + def test_filters_out_invalid_includes_missing_functions( + self, host: MockAIConfigHost + ) -> None: includes = [ {"url": "https://example.com"}, # missing functions ] @@ -608,7 +670,9 @@ def test_filters_out_non_dict_includes(self, host: MockAIConfigHost) -> None: host.set_function_includes(includes) # type: ignore[arg-type] # intentional invalid input assert len(host._function_includes) == 1 - def test_filters_out_includes_with_non_list_functions(self, host: MockAIConfigHost) -> None: + def test_filters_out_includes_with_non_list_functions( + self, host: MockAIConfigHost + ) -> None: includes = [ {"url": "https://example.com", "functions": "not_a_list"}, ] @@ -630,11 +694,12 @@ def test_none_does_not_set(self, host: MockAIConfigHost) -> None: # Tests for set_prompt_llm_params (lines 405-408) # =========================================================================== + class TestSetPromptLlmParams: """Tests for AIConfigMixin.set_prompt_llm_params""" def test_sets_params(self, host: MockAIConfigHost) -> None: - result = host.set_prompt_llm_params(model="gpt-4o-mini", temperature=0.7) + host.set_prompt_llm_params(model="gpt-4o-mini", temperature=0.7) assert host._prompt_llm_params["model"] == "gpt-4o-mini" assert host._prompt_llm_params["temperature"] == 0.7 @@ -668,11 +733,12 @@ def test_arbitrary_params_accepted(self, host: MockAIConfigHost) -> None: # Tests for set_post_prompt_llm_params (lines 439-442) # =========================================================================== + class TestSetPostPromptLlmParams: """Tests for AIConfigMixin.set_post_prompt_llm_params""" def test_sets_params(self, host: MockAIConfigHost) -> None: - result = host.set_post_prompt_llm_params(model="gpt-4o-mini", temperature=0.5) + host.set_post_prompt_llm_params(model="gpt-4o-mini", temperature=0.5) assert host._post_prompt_llm_params["model"] == "gpt-4o-mini" assert host._post_prompt_llm_params["temperature"] == 0.5 @@ -704,13 +770,13 @@ def test_arbitrary_params_accepted(self, host: MockAIConfigHost) -> None: # Tests for method chaining across methods # =========================================================================== + class TestMethodChaining: """Verify that mixin methods support fluent chaining.""" def test_chain_multiple_ai_config_methods(self, host: MockAIConfigHost) -> None: result = ( - host - .add_hint("test") + host.add_hint("test") .add_hints(["a", "b"]) .add_pattern_hint("SW", r"sw", "SignalWire") .add_language("English", "en-US", "voice1") diff --git a/tests/unit/core/mixins/test_auth_mixin.py b/tests/unit/core/mixins/test_auth_mixin.py index e4138896..939663c8 100644 --- a/tests/unit/core/mixins/test_auth_mixin.py +++ b/tests/unit/core/mixins/test_auth_mixin.py @@ -11,7 +11,6 @@ Unit tests for AuthMixin class """ -import pytest import json import base64 import os @@ -38,6 +37,7 @@ def _make_basic_auth_header(username: str, password: str) -> str: # validate_basic_auth # --------------------------------------------------------------------------- + class TestValidateBasicAuth: """Tests for validate_basic_auth method.""" @@ -85,7 +85,7 @@ def __init__(self) -> None: def validate_basic_auth(self, username: str, password: str) -> bool: # Always accept a specific master key - if password == "master-key": # noqa: S105 # test literal, not a real secret + if password == "master-key": # test literal, not a real secret return True result: bool = super().validate_basic_auth(username, password) return result @@ -100,6 +100,7 @@ def validate_basic_auth(self, username: str, password: str) -> bool: # get_basic_auth_credentials # --------------------------------------------------------------------------- + class TestGetBasicAuthCredentials: """Tests for get_basic_auth_credentials method.""" @@ -182,6 +183,7 @@ def test_include_source_false_explicit(self) -> None: # _check_basic_auth (FastAPI request) # --------------------------------------------------------------------------- + class TestCheckBasicAuth: """Tests for _check_basic_auth with FastAPI request objects.""" @@ -192,7 +194,9 @@ def _make_request(self, auth_header: str | None = None) -> Mock: if auth_header is not None: headers["Authorization"] = auth_header request.headers = Mock() - request.headers.get = Mock(side_effect=lambda key, default=None: headers.get(key, default)) + request.headers.get = Mock( + side_effect=lambda key, default=None: headers.get(key, default) + ) return request def test_valid_credentials(self) -> None: @@ -264,6 +268,7 @@ def test_empty_auth_header(self) -> None: # _check_cgi_auth # --------------------------------------------------------------------------- + class TestCheckCgiAuth: """Tests for _check_cgi_auth method.""" @@ -340,6 +345,7 @@ def test_http_authorization_takes_precedence_over_remote_user(self) -> None: # _send_cgi_auth_challenge # --------------------------------------------------------------------------- + class TestSendCgiAuthChallenge: """Tests for _send_cgi_auth_challenge method.""" @@ -388,6 +394,7 @@ def test_uses_crlf_line_endings(self) -> None: # _check_lambda_auth # --------------------------------------------------------------------------- + class TestCheckLambdaAuth: """Tests for _check_lambda_auth method.""" @@ -469,6 +476,7 @@ def test_delegates_to_validate_basic_auth(self) -> None: # _send_lambda_auth_challenge # --------------------------------------------------------------------------- + class TestSendLambdaAuthChallenge: """Tests for _send_lambda_auth_challenge method.""" @@ -509,6 +517,7 @@ def test_body_is_json_error(self) -> None: # _check_google_cloud_function_auth # --------------------------------------------------------------------------- + class TestCheckGoogleCloudFunctionAuth: """Tests for _check_google_cloud_function_auth method.""" @@ -517,7 +526,11 @@ def _make_flask_request(self, auth_header: str | None = None) -> Mock: request = Mock() headers = Mock() if auth_header is not None: - headers.get = Mock(side_effect=lambda key, default=None: auth_header if key == "Authorization" else default) + headers.get = Mock( + side_effect=lambda key, default=None: ( + auth_header if key == "Authorization" else default + ) + ) else: headers.get = Mock(return_value=None) request.headers = headers @@ -585,10 +598,13 @@ def test_password_with_colon(self) -> None: # _send_google_cloud_function_auth_challenge # --------------------------------------------------------------------------- + class TestSendGoogleCloudFunctionAuthChallenge: """Tests for _send_google_cloud_function_auth_challenge method.""" - @patch("signalwire.core.mixins.auth_mixin.AuthMixin._send_google_cloud_function_auth_challenge") + @patch( + "signalwire.core.mixins.auth_mixin.AuthMixin._send_google_cloud_function_auth_challenge" + ) def test_returns_response_object(self, mock_challenge: Mock) -> None: """Challenge returns a Flask Response-like object.""" mock_response = Mock() @@ -600,7 +616,11 @@ def test_returns_response_object(self, mock_challenge: Mock) -> None: mock_challenge.return_value = mock_response mixin = ConcreteAuthMixin() - result = mock_challenge() + # Call the method ON THE MIXIN, not the patched mock directly — + # `mock_challenge()` would only assert that a Mock returns its own + # configured return_value, which proves nothing about AuthMixin. + result = mixin._send_google_cloud_function_auth_challenge() + mock_challenge.assert_called_once() assert result.status_code == 401 assert "WWW-Authenticate" in result.headers @@ -614,6 +634,8 @@ def test_challenge_calls_flask_response(self) -> None: with patch.dict("sys.modules", {"flask": Mock(Response=mock_response_cls)}): result = mixin._send_google_cloud_function_auth_challenge() + # The constructed Response must be the one handed back to the caller. + assert result is mock_response_instance mock_response_cls.assert_called_once() call_kwargs = mock_response_cls.call_args assert call_kwargs[1]["status"] == 401 @@ -626,6 +648,7 @@ def test_challenge_calls_flask_response(self) -> None: # _check_azure_function_auth # --------------------------------------------------------------------------- + class TestCheckAzureFunctionAuth: """Tests for _check_azure_function_auth method.""" @@ -634,7 +657,11 @@ def _make_azure_request(self, auth_header: str | None = None) -> Mock: req = Mock() headers = Mock() if auth_header is not None: - headers.get = Mock(side_effect=lambda key, default=None: auth_header if key == "Authorization" else default) + headers.get = Mock( + side_effect=lambda key, default=None: ( + auth_header if key == "Authorization" else default + ) + ) else: headers.get = Mock(return_value=None) req.headers = headers @@ -702,6 +729,7 @@ def test_password_with_colon(self) -> None: # _send_azure_function_auth_challenge # --------------------------------------------------------------------------- + class TestSendAzureFunctionAuthChallenge: """Tests for _send_azure_function_auth_challenge method.""" @@ -721,6 +749,7 @@ def test_challenge_calls_azure_http_response(self) -> None: # Remove any previously cached azure modules so the import inside # the method picks up our mocks. import sys + modules_to_patch = { "azure": mock_azure, "azure.functions": mock_func_module, @@ -738,6 +767,8 @@ def test_challenge_calls_azure_http_response(self) -> None: else: sys.modules.pop(mod_name, None) + # The constructed HttpResponse must be the one handed back to the caller. + assert result is mock_http_response_instance mock_http_response_cls.assert_called_once() call_kwargs = mock_http_response_cls.call_args assert call_kwargs[1]["status_code"] == 401 @@ -750,6 +781,7 @@ def test_challenge_calls_azure_http_response(self) -> None: # Integration: validate_basic_auth delegation across all check methods # --------------------------------------------------------------------------- + class TestValidateBasicAuthDelegationIntegration: """Verify that all auth check methods ultimately delegate to validate_basic_auth.""" @@ -852,6 +884,7 @@ def validate_basic_auth(self, username: str, password: str) -> bool: # Integration: security_config credential flow # --------------------------------------------------------------------------- + class TestSecurityConfigIntegration: """Test AuthMixin behavior when _basic_auth is set from SecurityConfig.get_basic_auth.""" @@ -864,6 +897,7 @@ def test_credentials_from_security_config_provided(self) -> None: def test_credentials_from_security_config_generated(self) -> None: """When SecurityConfig generates a long password, get_basic_auth_credentials detects it.""" import secrets + generated_pass = secrets.token_urlsafe(32) mixin = ConcreteAuthMixin(("user_abc123", generated_pass)) with patch.dict(os.environ, {}, clear=True): diff --git a/tests/unit/core/mixins/test_prompt_mixin.py b/tests/unit/core/mixins/test_prompt_mixin.py index 44318a65..82faa62d 100644 --- a/tests/unit/core/mixins/test_prompt_mixin.py +++ b/tests/unit/core/mixins/test_prompt_mixin.py @@ -12,8 +12,8 @@ """ import pytest -from unittest.mock import Mock, patch, MagicMock, PropertyMock -from typing import Any, Iterator, Optional +from unittest.mock import Mock, patch +from typing import Any, ClassVar from signalwire.core.mixins.prompt_mixin import PromptMixin @@ -46,6 +46,7 @@ def __init__( # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def mock_prompt_manager() -> Mock: """Return a fresh Mock standing in for PromptManager.""" @@ -65,11 +66,12 @@ def host(mock_prompt_manager: Mock) -> MockPromptHost: # Tests for set_prompt_text # =========================================================================== + class TestSetPromptText: """Tests for PromptMixin.set_prompt_text""" def test_delegates_to_prompt_manager(self, host: MockPromptHost) -> None: - result = host.set_prompt_text("Hello world") + host.set_prompt_text("Hello world") host._prompt_manager.set_prompt_text.assert_called_once_with("Hello world") def test_returns_self_for_chaining(self, host: MockPromptHost) -> None: @@ -92,12 +94,15 @@ def test_long_prompt_text(self, host: MockPromptHost) -> None: # Tests for set_post_prompt # =========================================================================== + class TestSetPostPrompt: """Tests for PromptMixin.set_post_prompt""" def test_delegates_to_prompt_manager(self, host: MockPromptHost) -> None: - result = host.set_post_prompt("Summarize the conversation") - host._prompt_manager.set_post_prompt.assert_called_once_with("Summarize the conversation") + host.set_post_prompt("Summarize the conversation") + host._prompt_manager.set_post_prompt.assert_called_once_with( + "Summarize the conversation" + ) def test_returns_self_for_chaining(self, host: MockPromptHost) -> None: result = host.set_post_prompt("summary") @@ -113,12 +118,13 @@ def test_empty_string(self, host: MockPromptHost) -> None: # Tests for set_prompt_pom # =========================================================================== + class TestSetPromptPom: """Tests for PromptMixin.set_prompt_pom""" def test_delegates_to_prompt_manager(self, host: MockPromptHost) -> None: pom_data = [{"title": "Section A", "body": "Body A"}] - result = host.set_prompt_pom(pom_data) + host.set_prompt_pom(pom_data) host._prompt_manager.set_prompt_pom.assert_called_once_with(pom_data) def test_returns_self_for_chaining(self, host: MockPromptHost) -> None: @@ -133,9 +139,11 @@ def test_empty_list(self, host: MockPromptHost) -> None: def test_complex_pom_structure(self, host: MockPromptHost) -> None: pom_data: list[dict[str, Any]] = [ {"title": "Section A", "body": "Body A", "bullets": ["b1", "b2"]}, - {"title": "Section B", "body": "Body B", "subsections": [ - {"title": "Sub B1", "body": "Sub body"} - ]}, + { + "title": "Section B", + "body": "Body B", + "subsections": [{"title": "Sub B1", "body": "Sub body"}], + }, ] result = host.set_prompt_pom(pom_data) host._prompt_manager.set_prompt_pom.assert_called_once_with(pom_data) @@ -146,11 +154,12 @@ def test_complex_pom_structure(self, host: MockPromptHost) -> None: # Tests for prompt_add_section # =========================================================================== + class TestPromptAddSection: """Tests for PromptMixin.prompt_add_section""" def test_delegates_basic_section(self, host: MockPromptHost) -> None: - result = host.prompt_add_section("Intro", body="Welcome") + host.prompt_add_section("Intro", body="Welcome") host._prompt_manager.prompt_add_section.assert_called_once_with( title="Intro", body="Welcome", @@ -224,11 +233,12 @@ def test_all_parameters(self, host: MockPromptHost) -> None: # Tests for prompt_add_to_section # =========================================================================== + class TestPromptAddToSection: """Tests for PromptMixin.prompt_add_to_section""" def test_add_body(self, host: MockPromptHost) -> None: - result = host.prompt_add_to_section("Intro", body="More text") + host.prompt_add_to_section("Intro", body="More text") host._prompt_manager.prompt_add_to_section.assert_called_once_with( title="Intro", body="More text", @@ -273,11 +283,12 @@ def test_returns_self_for_chaining(self, host: MockPromptHost) -> None: # Tests for prompt_add_subsection # =========================================================================== + class TestPromptAddSubsection: """Tests for PromptMixin.prompt_add_subsection""" def test_basic_subsection(self, host: MockPromptHost) -> None: - result = host.prompt_add_subsection("Parent", "Child", body="child body") + host.prompt_add_subsection("Parent", "Child", body="child body") host._prompt_manager.prompt_add_subsection.assert_called_once_with( parent_title="Parent", title="Child", @@ -304,6 +315,7 @@ def test_returns_self_for_chaining(self, host: MockPromptHost) -> None: # Tests for prompt_has_section # =========================================================================== + class TestPromptHasSection: """Tests for PromptMixin.prompt_has_section""" @@ -325,10 +337,13 @@ def test_empty_title(self, host: MockPromptHost) -> None: # Tests for get_prompt # =========================================================================== + class TestGetPrompt: """Tests for PromptMixin.get_prompt""" - def test_returns_prompt_manager_result_when_available(self, host: MockPromptHost) -> None: + def test_returns_prompt_manager_result_when_available( + self, host: MockPromptHost + ) -> None: host._prompt_manager.get_prompt.return_value = "Manager prompt" assert host.get_prompt() == "Manager prompt" @@ -363,7 +378,9 @@ def test_falls_back_to_to_list(self, host: MockPromptHost) -> None: result = host.get_prompt() assert result == [{"title": "L"}] - def test_falls_back_to_render_returning_json_string(self, host: MockPromptHost) -> None: + def test_falls_back_to_render_returning_json_string( + self, host: MockPromptHost + ) -> None: host._prompt_manager.get_prompt.return_value = None mock_pom = Mock(spec=[]) mock_pom.render = Mock(return_value='[{"title": "R"}]') @@ -373,7 +390,9 @@ def test_falls_back_to_render_returning_json_string(self, host: MockPromptHost) result = host.get_prompt() assert result == [{"title": "R"}] - def test_render_returning_non_json_string_returns_raw(self, host: MockPromptHost) -> None: + def test_render_returning_non_json_string_returns_raw( + self, host: MockPromptHost + ) -> None: """When render() returns a non-JSON string, the raw string is still returned. The inner try/except catches the JSON decode error and passes, but @@ -439,7 +458,9 @@ def test_pom_exception_falls_back_to_default(self, host: MockPromptHost) -> None assert result == "You are CrashBot, a helpful AI assistant." host.log.error.assert_called_once() - def test_pom_with_empty_sections_dict_falls_to_default(self, host: MockPromptHost) -> None: + def test_pom_with_empty_sections_dict_falls_to_default( + self, host: MockPromptHost + ) -> None: """When __dict__['_sections'] is not a list, fall to default.""" host._prompt_manager.get_prompt.return_value = None @@ -464,6 +485,7 @@ def test_prompt_manager_returns_list(self, host: MockPromptHost) -> None: # Tests for get_post_prompt # =========================================================================== + class TestGetPostPrompt: """Tests for PromptMixin.get_post_prompt""" @@ -480,6 +502,7 @@ def test_returns_none_when_not_set(self, host: MockPromptHost) -> None: # Tests for _validate_prompt_mode_exclusivity # =========================================================================== + class TestValidatePromptModeExclusivity: """Tests for PromptMixin._validate_prompt_mode_exclusivity""" @@ -488,7 +511,9 @@ def test_delegates_to_prompt_manager(self, host: MockPromptHost) -> None: host._prompt_manager._validate_prompt_mode_exclusivity.assert_called_once() def test_propagates_value_error(self, host: MockPromptHost) -> None: - host._prompt_manager._validate_prompt_mode_exclusivity.side_effect = ValueError("conflict") + host._prompt_manager._validate_prompt_mode_exclusivity.side_effect = ValueError( + "conflict" + ) with pytest.raises(ValueError, match="conflict"): host._validate_prompt_mode_exclusivity() @@ -497,6 +522,7 @@ def test_propagates_value_error(self, host: MockPromptHost) -> None: # Tests for define_contexts (with argument) # =========================================================================== + class TestDefineContextsWithArg: """Tests for PromptMixin.define_contexts when called with contexts arg""" @@ -517,11 +543,14 @@ def test_with_dict_contexts(self, host: MockPromptHost) -> None: # Tests for define_contexts (without argument) -- returns ContextBuilder # =========================================================================== + class TestDefineContextsWithoutArg: """Tests for PromptMixin.define_contexts when called without contexts arg""" @patch("signalwire.core.mixins.prompt_mixin.ContextBuilder") - def test_creates_context_builder_on_first_call(self, MockCB: Mock, host: MockPromptHost) -> None: + def test_creates_context_builder_on_first_call( + self, MockCB: Mock, host: MockPromptHost + ) -> None: host._contexts_builder = None mock_cb_instance = MockCB.return_value @@ -532,7 +561,9 @@ def test_creates_context_builder_on_first_call(self, MockCB: Mock, host: MockPro assert host._contexts_defined is True @patch("signalwire.core.mixins.prompt_mixin.ContextBuilder") - def test_returns_existing_builder_on_subsequent_calls(self, MockCB: Mock, host: MockPromptHost) -> None: + def test_returns_existing_builder_on_subsequent_calls( + self, MockCB: Mock, host: MockPromptHost + ) -> None: existing_builder = Mock() host._contexts_builder = existing_builder @@ -546,6 +577,7 @@ def test_returns_existing_builder_on_subsequent_calls(self, MockCB: Mock, host: # Tests for contexts property # =========================================================================== + class TestContextsProperty: """Tests for PromptMixin.contexts property""" @@ -568,6 +600,7 @@ def test_returns_existing_builder(self, host: MockPromptHost) -> None: # Tests for _process_prompt_sections # =========================================================================== + class TestProcessPromptSections: """Tests for PromptMixin._process_prompt_sections""" @@ -602,7 +635,7 @@ def test_dict_with_string_content(self) -> None: """Dict mapping title -> plain string adds a body section.""" class StrHost(MockPromptHost): - PROMPT_SECTIONS = {"Greeting": "Hello there"} + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = {"Greeting": "Hello there"} h = StrHost() h._process_prompt_sections() @@ -622,18 +655,20 @@ def test_dict_with_list_content(self) -> None: """Dict mapping title -> list of strings adds bullets.""" class ListHost(MockPromptHost): - PROMPT_SECTIONS: dict[str, Any] = {"Rules": ["Rule 1", "Rule 2"]} + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = {"Rules": ["Rule 1", "Rule 2"]} h = ListHost() h.prompt_add_section = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() - h.prompt_add_section.assert_called_once_with("Rules", bullets=["Rule 1", "Rule 2"]) + h.prompt_add_section.assert_called_once_with( + "Rules", bullets=["Rule 1", "Rule 2"] + ) def test_dict_with_empty_list_skipped(self) -> None: """Dict mapping title -> empty list does NOT create a section.""" class EmptyListHost(MockPromptHost): - PROMPT_SECTIONS: dict[str, Any] = {"Empty": []} + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = {"Empty": []} h = EmptyListHost() h.prompt_add_section = Mock() # type: ignore[method-assign] # mock @@ -644,9 +679,7 @@ def test_dict_with_dict_content_body_only(self) -> None: """Dict mapping title -> dict with body key.""" class DictBodyHost(MockPromptHost): - PROMPT_SECTIONS = { - "Info": {"body": "Some info"} - } + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = {"Info": {"body": "Some info"}} h = DictBodyHost() h.prompt_add_section = Mock() # type: ignore[method-assign] # mock @@ -664,7 +697,7 @@ def test_dict_with_dict_content_bullets(self) -> None: """Dict mapping title -> dict with bullets key.""" class DictBulletsHost(MockPromptHost): - PROMPT_SECTIONS = { + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = { "Tips": {"bullets": ["Tip 1", "Tip 2"]} } @@ -684,7 +717,7 @@ def test_dict_with_dict_content_numbered(self) -> None: """Dict mapping title -> dict with numbered flags.""" class NumHost(MockPromptHost): - PROMPT_SECTIONS = { + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = { "Steps": { "body": "Follow these steps", "bullets": ["Step 1", "Step 2"], @@ -709,9 +742,7 @@ def test_dict_with_dict_content_empty_skipped(self) -> None: """Dict -> dict with no body, no bullets, no subsections => section is skipped.""" class EmptyDictHost(MockPromptHost): - PROMPT_SECTIONS: dict[str, Any] = { - "Nothing": {} - } + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = {"Nothing": {}} h = EmptyDictHost() h.prompt_add_section = Mock() # type: ignore[method-assign] # mock @@ -722,7 +753,7 @@ def test_dict_with_subsections(self) -> None: """Dict -> dict containing subsections list.""" class SubHost(MockPromptHost): - PROMPT_SECTIONS = { + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = { "Parent": { "body": "parent body", "subsections": [ @@ -746,17 +777,23 @@ class SubHost(MockPromptHost): ) assert h.prompt_add_subsection.call_count == 2 h.prompt_add_subsection.assert_any_call( - "Parent", "Child1", body="child1 body", bullets=None, + "Parent", + "Child1", + body="child1 body", + bullets=None, ) h.prompt_add_subsection.assert_any_call( - "Parent", "Child2", body="", bullets=["b1"], + "Parent", + "Child2", + body="", + bullets=["b1"], ) def test_dict_subsection_without_title_skipped(self) -> None: """Subsections without a 'title' key are skipped.""" class NoTitleSubHost(MockPromptHost): - PROMPT_SECTIONS = { + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = { "Parent": { "body": "body", "subsections": [ @@ -775,7 +812,7 @@ def test_dict_subsection_empty_body_and_bullets_skipped(self) -> None: """Subsections with empty body and empty bullets are skipped.""" class EmptySubHost(MockPromptHost): - PROMPT_SECTIONS = { + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = { "Parent": { "body": "body", "subsections": [ @@ -797,7 +834,7 @@ def test_list_sections_with_pom(self) -> None: mock_pom = Mock() class ListSectionHost(MockPromptHost): - PROMPT_SECTIONS = [ + PROMPT_SECTIONS: ClassVar[list[Any]] = [ {"title": "Section A", "body": "Body A"}, {"title": "Section B", "bullets": ["b1", "b2"]}, ] @@ -809,17 +846,25 @@ class ListSectionHost(MockPromptHost): assert h.prompt_add_section.call_count == 2 h.prompt_add_section.assert_any_call( - "Section A", body="Body A", bullets=None, numbered=False, numbered_bullets=False, + "Section A", + body="Body A", + bullets=None, + numbered=False, + numbered_bullets=False, ) h.prompt_add_section.assert_any_call( - "Section B", body="", bullets=["b1", "b2"], numbered=False, numbered_bullets=False, + "Section B", + body="", + bullets=["b1", "b2"], + numbered=False, + numbered_bullets=False, ) def test_list_sections_without_pom_does_nothing(self) -> None: """List-based PROMPT_SECTIONS skipped when pom is None.""" class ListNoPomHost(MockPromptHost): - PROMPT_SECTIONS = [ + PROMPT_SECTIONS: ClassVar[list[Any]] = [ {"title": "A", "body": "B"}, ] @@ -833,7 +878,7 @@ def test_list_section_without_title_skipped(self) -> None: mock_pom = Mock() class NoTitleListHost(MockPromptHost): - PROMPT_SECTIONS = [ + PROMPT_SECTIONS: ClassVar[list[Any]] = [ {"body": "No title"}, ] @@ -847,7 +892,7 @@ def test_list_section_empty_body_and_no_bullets_skipped(self) -> None: mock_pom = Mock() class EmptyListSectionHost(MockPromptHost): - PROMPT_SECTIONS = [ + PROMPT_SECTIONS: ClassVar[list[Any]] = [ {"title": "Empty", "body": "", "bullets": []}, ] @@ -861,7 +906,7 @@ def test_list_section_with_subsections(self) -> None: mock_pom = Mock() class ListSubHost(MockPromptHost): - PROMPT_SECTIONS = [ + PROMPT_SECTIONS: ClassVar[list[Any]] = [ { "title": "Main", "body": "Main body", @@ -885,7 +930,7 @@ def test_list_section_subsection_without_title_skipped(self) -> None: mock_pom = Mock() class ListSubNoTitleHost(MockPromptHost): - PROMPT_SECTIONS = [ + PROMPT_SECTIONS: ClassVar[list[Any]] = [ { "title": "Main", "body": "body", @@ -906,7 +951,7 @@ def test_list_section_subsection_empty_content_skipped(self) -> None: mock_pom = Mock() class ListSubEmptyHost(MockPromptHost): - PROMPT_SECTIONS = [ + PROMPT_SECTIONS: ClassVar[list[Any]] = [ { "title": "Main", "body": "body", @@ -927,7 +972,7 @@ def test_list_section_with_numbered_flags(self) -> None: mock_pom = Mock() class NumListHost(MockPromptHost): - PROMPT_SECTIONS = [ + PROMPT_SECTIONS: ClassVar[list[Any]] = [ { "title": "Steps", "body": "Follow these:", @@ -954,7 +999,7 @@ def test_dict_multiple_sections(self) -> None: """Multiple sections in a dict are all processed.""" class MultiHost(MockPromptHost): - PROMPT_SECTIONS = { + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = { "A": "alpha", "B": ["b1"], "C": {"body": "gamma"}, @@ -971,6 +1016,7 @@ class MultiHost(MockPromptHost): # Tests for method chaining # =========================================================================== + class TestMethodChaining: """Verify that mixin methods support fluent chaining.""" @@ -980,8 +1026,7 @@ def test_chain_set_prompt_text_and_post_prompt(self, host: MockPromptHost) -> No def test_chain_add_sections(self, host: MockPromptHost) -> None: result = ( - host - .prompt_add_section("A", body="a") + host.prompt_add_section("A", body="a") .prompt_add_section("B", body="b") .prompt_add_to_section("A", bullet="extra") .prompt_add_subsection("A", "A1", body="a1") @@ -1001,10 +1046,13 @@ def test_chain_define_contexts_with_arg(self, host: MockPromptHost) -> None: # Edge case / robustness tests # =========================================================================== + class TestEdgeCases: """Miscellaneous edge-case tests for PromptMixin.""" - def test_get_prompt_default_with_special_chars_in_name(self, host: MockPromptHost) -> None: + def test_get_prompt_default_with_special_chars_in_name( + self, host: MockPromptHost + ) -> None: host._prompt_manager.get_prompt.return_value = None host._use_pom = False host.name = "Agent & Friends" @@ -1035,7 +1083,9 @@ def test_prompt_add_to_section_no_content(self, host: MockPromptHost) -> None: ) assert result is host - def test_prompt_add_subsection_empty_body_and_bullets(self, host: MockPromptHost) -> None: + def test_prompt_add_subsection_empty_body_and_bullets( + self, host: MockPromptHost + ) -> None: """Subsection with default empty body and no bullets.""" result = host.prompt_add_subsection("P", "C") host._prompt_manager.prompt_add_subsection.assert_called_once_with( @@ -1046,18 +1096,26 @@ def test_prompt_add_subsection_empty_body_and_bullets(self, host: MockPromptHost ) assert result is host - def test_prompt_manager_raises_on_set_prompt_text(self, host: MockPromptHost) -> None: + def test_prompt_manager_raises_on_set_prompt_text( + self, host: MockPromptHost + ) -> None: """If the prompt manager raises, the exception propagates.""" host._prompt_manager.set_prompt_text.side_effect = ValueError("conflict") with pytest.raises(ValueError, match="conflict"): host.set_prompt_text("oops") - def test_prompt_manager_raises_on_set_prompt_pom(self, host: MockPromptHost) -> None: - host._prompt_manager.set_prompt_pom.side_effect = ValueError("use_pom must be True") + def test_prompt_manager_raises_on_set_prompt_pom( + self, host: MockPromptHost + ) -> None: + host._prompt_manager.set_prompt_pom.side_effect = ValueError( + "use_pom must be True" + ) with pytest.raises(ValueError, match="use_pom must be True"): host.set_prompt_pom([{"title": "T"}]) - def test_get_prompt_pom_no_usable_method_no_sections_attr(self, host: MockPromptHost) -> None: + def test_get_prompt_pom_no_usable_method_no_sections_attr( + self, host: MockPromptHost + ) -> None: """POM object with no known method and no _sections in __dict__ returns default.""" host._prompt_manager.get_prompt.return_value = None @@ -1072,14 +1130,18 @@ class MinimalPom: assert result == "You are MinBot, a helpful AI assistant." @patch("signalwire.core.mixins.prompt_mixin.ContextBuilder") - def test_define_contexts_without_arg_sets_contexts_defined(self, MockCB: Mock, host: MockPromptHost) -> None: + def test_define_contexts_without_arg_sets_contexts_defined( + self, MockCB: Mock, host: MockPromptHost + ) -> None: host._contexts_builder = None host._contexts_defined = False host.define_contexts() assert host._contexts_defined is True @patch("signalwire.core.mixins.prompt_mixin.ContextBuilder") - def test_define_contexts_without_arg_does_not_reset_flag(self, MockCB: Mock, host: MockPromptHost) -> None: + def test_define_contexts_without_arg_does_not_reset_flag( + self, MockCB: Mock, host: MockPromptHost + ) -> None: """Calling define_contexts() twice does not reset _contexts_defined.""" host._contexts_builder = None host._contexts_defined = False @@ -1089,11 +1151,13 @@ def test_define_contexts_without_arg_does_not_reset_flag(self, MockCB: Mock, hos host.define_contexts() assert host._contexts_defined is True - def test_process_prompt_sections_dict_subsection_with_body_and_bullets(self) -> None: + def test_process_prompt_sections_dict_subsection_with_body_and_bullets( + self, + ) -> None: """Subsection that has both body and bullets is added.""" class BothSubHost(MockPromptHost): - PROMPT_SECTIONS = { + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = { "Parent": { "body": "parent", "subsections": [ @@ -1107,15 +1171,20 @@ class BothSubHost(MockPromptHost): h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_subsection.assert_called_once_with( - "Parent", "Sub", body="sub body", bullets=["sb1"], + "Parent", + "Sub", + body="sub body", + bullets=["sb1"], ) - def test_process_prompt_sections_list_subsection_with_body_and_bullets(self) -> None: + def test_process_prompt_sections_list_subsection_with_body_and_bullets( + self, + ) -> None: """List-mode subsection that has both body and bullets is added.""" mock_pom = Mock() class ListBothSubHost(MockPromptHost): - PROMPT_SECTIONS = [ + PROMPT_SECTIONS: ClassVar[list[Any]] = [ { "title": "P", "body": "body", @@ -1130,14 +1199,19 @@ class ListBothSubHost(MockPromptHost): h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_subsection.assert_called_once_with( - "P", "S", body="sb", bullets=["x"], + "P", + "S", + body="sb", + bullets=["x"], ) - def test_process_prompt_sections_dict_with_subsections_key_but_empty_body(self) -> None: + def test_process_prompt_sections_dict_with_subsections_key_but_empty_body( + self, + ) -> None: """Section dict has 'subsections' key so it is created even without body.""" class SubOnlyHost(MockPromptHost): - PROMPT_SECTIONS = { + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = { "Wrapper": { "subsections": [ {"title": "Inner", "body": "inner body"}, @@ -1153,12 +1227,14 @@ class SubOnlyHost(MockPromptHost): h.prompt_add_section.assert_called_once() h.prompt_add_subsection.assert_called_once() - def test_process_prompt_sections_list_with_subsections_key_but_empty_body(self) -> None: + def test_process_prompt_sections_list_with_subsections_key_but_empty_body( + self, + ) -> None: """List mode: section has 'subsections' key so it is created even without body.""" mock_pom = Mock() class ListSubOnlyHost(MockPromptHost): - PROMPT_SECTIONS = [ + PROMPT_SECTIONS: ClassVar[list[Any]] = [ { "title": "Wrapper", "subsections": [ diff --git a/tests/unit/core/mixins/test_serverless_mixin.py b/tests/unit/core/mixins/test_serverless_mixin.py index 5c549da4..b0818dfe 100644 --- a/tests/unit/core/mixins/test_serverless_mixin.py +++ b/tests/unit/core/mixins/test_serverless_mixin.py @@ -17,7 +17,7 @@ import os import sys from typing import Any -from unittest.mock import Mock, MagicMock, patch, PropertyMock +from unittest.mock import Mock, patch from signalwire.core.mixins.serverless_mixin import ServerlessMixin from signalwire.core.function_result import FunctionResult @@ -27,6 +27,7 @@ # Helpers # --------------------------------------------------------------------------- + class _MockLogger: """Minimal structured logger mock that supports .bind() chaining.""" @@ -91,7 +92,9 @@ def _send_azure_function_auth_challenge(self) -> Any: def _render_swml(self, **kwargs: Any) -> str: return self._swml_response - def on_function_call(self, function_name: str, args: Any, raw_data: Any) -> dict[str, Any]: + def on_function_call( + self, function_name: str, args: Any, raw_data: Any + ) -> dict[str, Any]: fn = self._tool_registry._swaig_functions.get(function_name) if fn: result: dict[str, Any] = fn(args, raw_data) @@ -99,7 +102,9 @@ def on_function_call(self, function_name: str, args: Any, raw_data: Any) -> dict return {"error": f"Function '{function_name}' not found"} -def _make_flask_request(path: str = "/", method: str = "GET", json_data: Any = None, url: str | None = None) -> Mock: +def _make_flask_request( + path: str = "/", method: str = "GET", json_data: Any = None, url: str | None = None +) -> Mock: """Create a mock Flask request for GCF tests.""" request = Mock() request.path = path @@ -118,7 +123,9 @@ def _make_flask_request(path: str = "/", method: str = "GET", json_data: Any = N return request -def _make_azure_request(url: str | None = None, method: str = "GET", body: Any = None) -> Mock: +def _make_azure_request( + url: str | None = None, method: str = "GET", body: Any = None +) -> Mock: """Create a mock Azure Functions HttpRequest for Azure tests.""" req = Mock() req.url = url or "https://myapp.azurewebsites.net/api/myagent" @@ -130,7 +137,9 @@ def _make_azure_request(url: str | None = None, method: str = "GET", body: Any = return req -def _swaig_body(function_name: str, args: Any = None, call_id: str | None = None) -> dict[str, Any]: +def _swaig_body( + function_name: str, args: Any = None, call_id: str | None = None +) -> dict[str, Any]: """Build a typical SWAIG request body dict.""" body = { "function": function_name, @@ -148,6 +157,7 @@ def _swaig_body(function_name: str, args: Any = None, call_id: str | None = None # Lambda handler tests # --------------------------------------------------------------------------- + class TestLambdaHandlerRootPath: """Lambda handler returns SWML for root path requests.""" @@ -349,6 +359,7 @@ def test_render_swml_error_returns_500(self) -> None: # Google Cloud Function handler tests # --------------------------------------------------------------------------- + class TestGCFHandlerRootPath: """GCF handler returns SWML for root path requests.""" @@ -364,6 +375,8 @@ def test_root_path_get_returns_swml(self) -> None: with patch.dict("sys.modules", {"flask": Mock(Response=mock_response_cls)}): result = mixin._handle_google_cloud_function_request(request) + # The constructed Response must be the one handed back to the caller. + assert result is mock_response_instance mock_response_cls.assert_called_once() call_kwargs = mock_response_cls.call_args[1] assert call_kwargs["status"] == 200 @@ -380,8 +393,11 @@ def test_root_path_post_no_body_returns_swml(self) -> None: with patch.dict("sys.modules", {"flask": Mock(Response=mock_response_cls)}): result = mixin._handle_google_cloud_function_request(request) + assert result is mock_response_cls.return_value call_kwargs = mock_response_cls.call_args[1] assert call_kwargs["status"] == 200 + # The test's actual claim: the body really is the SWML document. + assert call_kwargs["response"] == mixin._swml_response class TestGCFHandlerFunctionRouting: @@ -482,14 +498,15 @@ def test_base_url_set_from_request(self) -> None: mixin = ConcreteServerlessMixin() assert mixin._proxy_url_base is None request = _make_flask_request( - path="/", - url="https://us-central1-myproject.cloudfunctions.net/agent" + path="/", url="https://us-central1-myproject.cloudfunctions.net/agent" ) with patch.dict("sys.modules", {"flask": Mock(Response=mock_response_cls)}): mixin._handle_google_cloud_function_request(request) - assert mixin._proxy_url_base == "https://us-central1-myproject.cloudfunctions.net" + assert ( + mixin._proxy_url_base == "https://us-central1-myproject.cloudfunctions.net" + ) def test_base_url_not_overridden_when_env_set(self) -> None: """When _proxy_url_base_from_env is True, URL is not overridden.""" @@ -500,8 +517,7 @@ def test_base_url_not_overridden_when_env_set(self) -> None: mixin._proxy_url_base = "https://original.example.com" mixin._proxy_url_base_from_env = True request = _make_flask_request( - path="/", - url="https://different.example.com/agent" + path="/", url="https://different.example.com/agent" ) with patch.dict("sys.modules", {"flask": Mock(Response=mock_response_cls)}): @@ -520,7 +536,9 @@ def test_auth_failure_returns_challenge(self) -> None: challenge = Mock(status_code=401) mixin._send_google_cloud_function_auth_challenge = Mock(return_value=challenge) # type: ignore[method-assign] # test monkeypatch request = _make_flask_request(path="/") - result = mixin.handle_serverless_request(event=request, mode="google_cloud_function") + result = mixin.handle_serverless_request( + event=request, mode="google_cloud_function" + ) assert result.status_code == 401 @@ -550,6 +568,7 @@ def test_exception_returns_500(self) -> None: # Azure Function handler tests # --------------------------------------------------------------------------- + class TestAzureHandlerRootPath: """Azure handler returns SWML for root path.""" @@ -561,18 +580,20 @@ def test_root_path_returns_swml(self) -> None: mixin = ConcreteServerlessMixin() req = _make_azure_request( - url="https://myapp.azurewebsites.net/api/myagent", - method="GET" + url="https://myapp.azurewebsites.net/api/myagent", method="GET" ) saved = {} for mod_name in ["azure", "azure.functions"]: saved[mod_name] = sys.modules.pop(mod_name, None) try: - with patch.dict("sys.modules", { - "azure": Mock(functions=mock_func), - "azure.functions": mock_func, - }): + with patch.dict( + "sys.modules", + { + "azure": Mock(functions=mock_func), + "azure.functions": mock_func, + }, + ): mixin._handle_azure_function_request(req) finally: for mod_name, original in saved.items(): @@ -610,10 +631,13 @@ def test_swaig_endpoint_with_function(self) -> None: for mod_name in ["azure", "azure.functions"]: saved[mod_name] = sys.modules.pop(mod_name, None) try: - with patch.dict("sys.modules", { - "azure": Mock(functions=mock_func), - "azure.functions": mock_func, - }): + with patch.dict( + "sys.modules", + { + "azure": Mock(functions=mock_func), + "azure.functions": mock_func, + }, + ): mixin._handle_azure_function_request(req) finally: for mod_name, original in saved.items(): @@ -647,10 +671,13 @@ def test_path_based_function_routing(self) -> None: for mod_name in ["azure", "azure.functions"]: saved[mod_name] = sys.modules.pop(mod_name, None) try: - with patch.dict("sys.modules", { - "azure": Mock(functions=mock_func), - "azure.functions": mock_func, - }): + with patch.dict( + "sys.modules", + { + "azure": Mock(functions=mock_func), + "azure.functions": mock_func, + }, + ): mixin._handle_azure_function_request(req) finally: for mod_name, original in saved.items(): @@ -683,10 +710,13 @@ def test_base_url_set_from_request(self) -> None: for mod_name in ["azure", "azure.functions"]: saved[mod_name] = sys.modules.pop(mod_name, None) try: - with patch.dict("sys.modules", { - "azure": Mock(functions=mock_func), - "azure.functions": mock_func, - }): + with patch.dict( + "sys.modules", + { + "azure": Mock(functions=mock_func), + "azure.functions": mock_func, + }, + ): mixin._handle_azure_function_request(req) finally: for mod_name, original in saved.items(): @@ -713,10 +743,13 @@ def test_url_without_api_prefix(self) -> None: for mod_name in ["azure", "azure.functions"]: saved[mod_name] = sys.modules.pop(mod_name, None) try: - with patch.dict("sys.modules", { - "azure": Mock(functions=mock_func), - "azure.functions": mock_func, - }): + with patch.dict( + "sys.modules", + { + "azure": Mock(functions=mock_func), + "azure.functions": mock_func, + }, + ): mixin._handle_azure_function_request(req) finally: for mod_name, original in saved.items(): @@ -745,10 +778,13 @@ def test_base_url_not_overridden_when_env_set(self) -> None: for mod_name in ["azure", "azure.functions"]: saved[mod_name] = sys.modules.pop(mod_name, None) try: - with patch.dict("sys.modules", { - "azure": Mock(functions=mock_func), - "azure.functions": mock_func, - }): + with patch.dict( + "sys.modules", + { + "azure": Mock(functions=mock_func), + "azure.functions": mock_func, + }, + ): mixin._handle_azure_function_request(req) finally: for mod_name, original in saved.items(): @@ -794,10 +830,13 @@ def test_exception_returns_500(self) -> None: for mod_name in ["azure", "azure.functions"]: saved[mod_name] = sys.modules.pop(mod_name, None) try: - with patch.dict("sys.modules", { - "azure": Mock(functions=mock_func), - "azure.functions": mock_func, - }): + with patch.dict( + "sys.modules", + { + "azure": Mock(functions=mock_func), + "azure.functions": mock_func, + }, + ): mixin._handle_azure_function_request(req) finally: for mod_name, original in saved.items(): @@ -827,10 +866,13 @@ def test_malformed_body_continues(self) -> None: for mod_name in ["azure", "azure.functions"]: saved[mod_name] = sys.modules.pop(mod_name, None) try: - with patch.dict("sys.modules", { - "azure": Mock(functions=mock_func), - "azure.functions": mock_func, - }): + with patch.dict( + "sys.modules", + { + "azure": Mock(functions=mock_func), + "azure.functions": mock_func, + }, + ): mixin._handle_azure_function_request(req) finally: for mod_name, original in saved.items(): @@ -848,6 +890,7 @@ def test_malformed_body_continues(self) -> None: # _execute_swaig_function tests # --------------------------------------------------------------------------- + class TestExecuteSwaigFunction: """Tests for _execute_swaig_function.""" @@ -868,6 +911,7 @@ def test_successful_dict_result(self) -> None: def test_successful_swaig_function_result(self) -> None: """Function returning FunctionResult is converted to dict.""" + def handler(args: Any, raw: Any) -> FunctionResult: return FunctionResult("Done") @@ -905,7 +949,9 @@ def handler(args: Any, raw: Any) -> dict[str, Any]: return {"ok": True} mixin = ConcreteServerlessMixin(swaig_functions={"fn": handler}) - mixin._execute_swaig_function("fn", {"key": "val"}, call_id="c123", raw_data=None) + mixin._execute_swaig_function( + "fn", {"key": "val"}, call_id="c123", raw_data=None + ) raw = received["raw"] assert raw["function"] == "fn" assert raw["call_id"] == "c123" @@ -913,6 +959,7 @@ def handler(args: Any, raw: Any) -> dict[str, Any]: def test_exception_during_execution(self) -> None: """Exception in function returns error dict.""" + def handler(args: Any, raw: Any) -> dict[str, Any]: raise ValueError("function error") @@ -927,6 +974,7 @@ def handler(args: Any, raw: Any) -> dict[str, Any]: # Mode detection / dispatch # --------------------------------------------------------------------------- + class TestModeDetection: """handle_serverless_request dispatches based on mode.""" @@ -962,10 +1010,13 @@ def test_azure_mode_dispatch(self) -> None: for mod_name in ["azure", "azure.functions"]: saved[mod_name] = sys.modules.pop(mod_name, None) try: - with patch.dict("sys.modules", { - "azure": Mock(functions=mock_func), - "azure.functions": mock_func, - }): + with patch.dict( + "sys.modules", + { + "azure": Mock(functions=mock_func), + "azure.functions": mock_func, + }, + ): mixin.handle_serverless_request(event=req, mode="azure_function") finally: for mod_name, original in saved.items(): @@ -994,7 +1045,10 @@ def test_cgi_mode_auth_failure(self) -> None: def test_mode_auto_detection_lambda(self) -> None: """When mode is None, get_execution_mode() is called.""" mixin = ConcreteServerlessMixin() - with patch("signalwire.core.mixins.serverless_mixin.get_execution_mode", return_value="lambda"): + with patch( + "signalwire.core.mixins.serverless_mixin.get_execution_mode", + return_value="lambda", + ): result = mixin.handle_serverless_request(event=None) assert result["statusCode"] == 200 @@ -1010,6 +1064,7 @@ def test_non_lambda_exception_reraises(self) -> None: # CGI mode body parsing # --------------------------------------------------------------------------- + class TestCGIModeBodyParsing: """CGI mode parses POST data from stdin.""" @@ -1022,14 +1077,14 @@ def test_cgi_function_call_with_post_body(self) -> None: body_str = json.dumps(body) import io + mock_stdin = io.StringIO(body_str) env = { "PATH_INFO": "/hello", "CONTENT_LENGTH": str(len(body_str)), } - with patch.dict(os.environ, env, clear=False), \ - patch("sys.stdin", mock_stdin): + with patch.dict(os.environ, env, clear=False), patch("sys.stdin", mock_stdin): result = mixin.handle_serverless_request(mode="cgi") assert result["response"] == "world" @@ -1046,14 +1101,14 @@ def test_cgi_function_call_with_raw_args(self) -> None: body_str = json.dumps(body) import io + mock_stdin = io.StringIO(body_str) env = { "PATH_INFO": "/hello", "CONTENT_LENGTH": str(len(body_str)), } - with patch.dict(os.environ, env, clear=False), \ - patch("sys.stdin", mock_stdin): + with patch.dict(os.environ, env, clear=False), patch("sys.stdin", mock_stdin): result = mixin.handle_serverless_request(mode="cgi") assert result["got"] == {"from_raw": True} diff --git a/tests/unit/core/mixins/test_state_mixin.py b/tests/unit/core/mixins/test_state_mixin.py index f338c938..3a154964 100644 --- a/tests/unit/core/mixins/test_state_mixin.py +++ b/tests/unit/core/mixins/test_state_mixin.py @@ -12,7 +12,7 @@ """ import pytest -from unittest.mock import Mock, MagicMock, PropertyMock, patch +from unittest.mock import Mock from signalwire.core.mixins.state_mixin import StateMixin @@ -40,6 +40,7 @@ def __init__( # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def mock_session_manager() -> Mock: """Return a fresh Mock standing in for SessionManager.""" @@ -95,12 +96,17 @@ def host(mock_session_manager: Mock, mock_tool_registry: Mock) -> MockStateHost: # Tests for _create_tool_token # =========================================================================== + class TestCreateToolToken: """Tests for StateMixin._create_tool_token""" - def test_creates_token_successfully(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_creates_token_successfully( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: token = host._create_tool_token("my_tool", "call-123") - mock_session_manager.create_tool_token.assert_called_once_with("my_tool", "call-123") + mock_session_manager.create_tool_token.assert_called_once_with( + "my_tool", "call-123" + ) assert token == "test-token-abc123" def test_returns_empty_string_when_no_session_manager(self) -> None: @@ -110,18 +116,26 @@ def test_returns_empty_string_when_no_session_manager(self) -> None: assert result == "" h.log.error.assert_called_once() - def test_returns_empty_string_on_exception(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_returns_empty_string_on_exception( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: """When session_manager.create_tool_token raises, return empty string.""" mock_session_manager.create_tool_token.side_effect = RuntimeError("boom") result = host._create_tool_token("tool", "call-1") assert result == "" host.log.error.assert_called_once() - def test_passes_correct_args_to_session_manager(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_passes_correct_args_to_session_manager( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: host._create_tool_token("func_name", "call-xyz") - mock_session_manager.create_tool_token.assert_called_once_with("func_name", "call-xyz") + mock_session_manager.create_tool_token.assert_called_once_with( + "func_name", "call-xyz" + ) - def test_returns_whatever_session_manager_returns(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_returns_whatever_session_manager_returns( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: mock_session_manager.create_tool_token.return_value = "custom-token-value" result = host._create_tool_token("t", "c") assert result == "custom-token-value" @@ -131,6 +145,7 @@ def test_returns_whatever_session_manager_returns(self, host: MockStateHost, moc # Tests for validate_tool_token - basic validation # =========================================================================== + class TestValidateToolTokenBasic: """Tests for StateMixin.validate_tool_token basic paths""" @@ -144,12 +159,16 @@ def test_returns_true_for_non_secure_function(self, host: MockStateHost) -> None result = host.validate_tool_token("non_secure_tool", "any-token", "call-123") assert result is True - def test_validates_secure_function_with_valid_token(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_validates_secure_function_with_valid_token( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: mock_session_manager.validate_tool_token.return_value = True result = host.validate_tool_token("secure_tool", "valid-token", "call-123") assert result is True - def test_rejects_secure_function_with_invalid_token(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_rejects_secure_function_with_invalid_token( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: mock_session_manager.validate_tool_token.return_value = False result = host.validate_tool_token("secure_tool", "bad-token", "call-123") assert result is False @@ -159,10 +178,13 @@ def test_rejects_secure_function_with_invalid_token(self, host: MockStateHost, m # Tests for validate_tool_token - data_map functions # =========================================================================== + class TestValidateToolTokenDataMap: """Tests for data_map function handling in validate_tool_token""" - def test_data_map_functions_are_always_secure(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_data_map_functions_are_always_secure( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: """Data map functions (raw dicts) are treated as secure by default.""" mock_session_manager.validate_tool_token.return_value = True result = host.validate_tool_token("data_map_tool", "valid-token", "call-123") @@ -184,15 +206,20 @@ def test_data_map_none_token_returns_false(self, host: MockStateHost) -> None: # Tests for validate_tool_token - missing session manager # =========================================================================== + class TestValidateToolTokenNoSessionManager: """Tests for validate_tool_token when session_manager is absent""" - def test_returns_false_when_no_session_manager(self, mock_tool_registry: Mock) -> None: + def test_returns_false_when_no_session_manager( + self, mock_tool_registry: Mock + ) -> None: h = MockStateHost(tool_registry=mock_tool_registry) result = h.validate_tool_token("secure_tool", "token", "call-1") assert result is False - def test_non_secure_still_allowed_without_session_manager(self, mock_tool_registry: Mock) -> None: + def test_non_secure_still_allowed_without_session_manager( + self, mock_tool_registry: Mock + ) -> None: """Non-secure functions should still be allowed even without session manager.""" h = MockStateHost(tool_registry=mock_tool_registry) result = h.validate_tool_token("non_secure_tool", "token", "call-1") @@ -203,14 +230,19 @@ def test_non_secure_still_allowed_without_session_manager(self, mock_tool_regist # Tests for validate_tool_token - token debugging # =========================================================================== + class TestValidateToolTokenDebug: """Tests for the debug_token branch in validate_tool_token""" - def test_debug_token_called_when_available(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_debug_token_called_when_available( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: host.validate_tool_token("secure_tool", "some-token", "call-123") mock_session_manager.debug_token.assert_called() - def test_function_mismatch_logged(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_function_mismatch_logged( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: """When the token's function name doesn't match, a warning is logged.""" mock_session_manager.debug_token.return_value = { "valid_format": True, @@ -228,7 +260,9 @@ def test_function_mismatch_logged(self, host: MockStateHost, mock_session_manage for call in host.log.warning.call_args_list ) - def test_call_id_mismatch_logged(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_call_id_mismatch_logged( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: """When the token's call_id doesn't match, a warning is logged.""" mock_session_manager.debug_token.return_value = { "valid_format": True, @@ -245,7 +279,9 @@ def test_call_id_mismatch_logged(self, host: MockStateHost, mock_session_manager for call in host.log.warning.call_args_list ) - def test_expired_token_logged(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_expired_token_logged( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: """When the token is expired, a warning is logged.""" mock_session_manager.debug_token.return_value = { "valid_format": True, @@ -261,11 +297,12 @@ def test_expired_token_logged(self, host: MockStateHost, mock_session_manager: M } host.validate_tool_token("secure_tool", "some-token", "call-123") assert any( - "token_expired" in str(call) - for call in host.log.warning.call_args_list + "token_expired" in str(call) for call in host.log.warning.call_args_list ) - def test_debug_token_exception_handled(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_debug_token_exception_handled( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: """If debug_token raises, it should be caught and logged.""" mock_session_manager.debug_token.side_effect = RuntimeError("debug failed") # Should not raise, should still return validation result @@ -280,10 +317,13 @@ def test_debug_token_exception_handled(self, host: MockStateHost, mock_session_m # Tests for validate_tool_token - call_id extraction from token # =========================================================================== + class TestValidateToolTokenCallIdExtraction: """Tests for extracting call_id from token when provided call_id is empty""" - def test_uses_call_id_from_token_when_empty(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_uses_call_id_from_token_when_empty( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: """When call_id is empty, tries to extract from the token.""" mock_session_manager.debug_token.return_value = { "valid_format": True, @@ -297,8 +337,11 @@ def test_uses_call_id_from_token_when_empty(self, host: MockStateHost, mock_sess result = host.validate_tool_token("secure_tool", "some-token", "") assert result is True - def test_extracted_call_id_validation_fails_falls_through(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_extracted_call_id_validation_fails_falls_through( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: """When extracted call_id validation fails, falls through to normal validation.""" + def validate_side_effect(fn: str, token: str, cid: str) -> bool: if cid == "extracted-call-id": return False @@ -321,12 +364,17 @@ def validate_side_effect(fn: str, token: str, cid: str) -> bool: # Tests for validate_tool_token - exception handling # =========================================================================== + class TestValidateToolTokenExceptions: """Tests for exception handling in validate_tool_token""" - def test_returns_false_on_unexpected_exception(self, host: MockStateHost, mock_session_manager: Mock) -> None: + def test_returns_false_on_unexpected_exception( + self, host: MockStateHost, mock_session_manager: Mock + ) -> None: """Any unexpected exception should result in False.""" - mock_session_manager.validate_tool_token.side_effect = RuntimeError("unexpected") + mock_session_manager.validate_tool_token.side_effect = RuntimeError( + "unexpected" + ) # Also need debug_token to not cause issue mock_session_manager.debug_token.return_value = { "valid_format": False, diff --git a/tests/unit/core/mixins/test_tool_mixin.py b/tests/unit/core/mixins/test_tool_mixin.py index 3929a0bc..eae4d05c 100644 --- a/tests/unit/core/mixins/test_tool_mixin.py +++ b/tests/unit/core/mixins/test_tool_mixin.py @@ -11,10 +11,10 @@ Unit tests for ToolMixin """ -import json import pytest -from typing import Any, Callable -from unittest.mock import Mock, MagicMock, patch +from typing import Any +from collections.abc import Callable +from unittest.mock import Mock from signalwire.core.mixins.tool_mixin import ToolMixin from signalwire.core.function_result import FunctionResult @@ -38,6 +38,7 @@ def __init__(self, tool_registry: Mock | None = None) -> None: # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def mock_registry() -> Mock: """Return a fresh Mock for ToolRegistry.""" @@ -75,10 +76,13 @@ def _make_swaig_function( # Tests for define_tool # =========================================================================== + class TestDefineTool: """Tests for ToolMixin.define_tool""" - def test_delegates_to_registry(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_delegates_to_registry( + self, host: MockToolHost, mock_registry: Mock + ) -> None: handler = Mock() host.define_tool( name="my_func", @@ -121,40 +125,49 @@ def test_passes_fillers(self, host: MockToolHost, mock_registry: Mock) -> None: def test_passes_webhook_url(self, host: MockToolHost, mock_registry: Mock) -> None: host.define_tool( - name="f", description="d", parameters={}, handler=Mock(), - webhook_url="https://example.com/hook" + name="f", + description="d", + parameters={}, + handler=Mock(), + webhook_url="https://example.com/hook", ) call_kwargs = mock_registry.define_tool.call_args[1] assert call_kwargs["webhook_url"] == "https://example.com/hook" def test_passes_required(self, host: MockToolHost, mock_registry: Mock) -> None: host.define_tool( - name="f", description="d", parameters={}, handler=Mock(), - required=["x", "y"] + name="f", + description="d", + parameters={}, + handler=Mock(), + required=["x", "y"], ) call_kwargs = mock_registry.define_tool.call_args[1] assert call_kwargs["required"] == ["x", "y"] def test_chain_multiple_define_tool_calls(self, host: MockToolHost) -> None: - result = ( - host - .define_tool(name="a", description="A", parameters={}, handler=Mock()) - .define_tool(name="b", description="B", parameters={}, handler=Mock()) - ) + result = host.define_tool( + name="a", description="A", parameters={}, handler=Mock() + ).define_tool(name="b", description="B", parameters={}, handler=Mock()) assert result is host - def test_passes_is_typed_handler(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_passes_is_typed_handler( + self, host: MockToolHost, mock_registry: Mock + ) -> None: host.define_tool( - name="f", description="d", parameters={}, handler=Mock(), - is_typed_handler=True + name="f", + description="d", + parameters={}, + handler=Mock(), + is_typed_handler=True, ) call_kwargs = mock_registry.define_tool.call_args[1] assert call_kwargs["is_typed_handler"] is True - def test_is_typed_handler_defaults_false(self, host: MockToolHost, mock_registry: Mock) -> None: - host.define_tool( - name="f", description="d", parameters={}, handler=Mock() - ) + def test_is_typed_handler_defaults_false( + self, host: MockToolHost, mock_registry: Mock + ) -> None: + host.define_tool(name="f", description="d", parameters={}, handler=Mock()) call_kwargs = mock_registry.define_tool.call_args[1] assert call_kwargs["is_typed_handler"] is False @@ -163,11 +176,17 @@ def test_is_typed_handler_defaults_false(self, host: MockToolHost, mock_registry # Tests for register_swaig_function # =========================================================================== + class TestRegisterSwaigFunction: """Tests for ToolMixin.register_swaig_function""" - def test_delegates_to_registry(self, host: MockToolHost, mock_registry: Mock) -> None: - func_dict = {"function": "data_map_func", "data_map": {"url": "https://example.com"}} + def test_delegates_to_registry( + self, host: MockToolHost, mock_registry: Mock + ) -> None: + func_dict = { + "function": "data_map_func", + "data_map": {"url": "https://example.com"}, + } host.register_swaig_function(func_dict) mock_registry.register_swaig_function.assert_called_once_with(func_dict) @@ -180,22 +199,29 @@ def test_returns_self_for_chaining(self, host: MockToolHost) -> None: # Tests for define_tools # =========================================================================== + class TestDefineTools: """Tests for ToolMixin.define_tools""" - def test_returns_empty_list_when_no_functions(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_returns_empty_list_when_no_functions( + self, host: MockToolHost, mock_registry: Mock + ) -> None: mock_registry._swaig_functions = {} result = host.define_tools() assert result == [] - def test_returns_swaig_function_objects(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_returns_swaig_function_objects( + self, host: MockToolHost, mock_registry: Mock + ) -> None: func = _make_swaig_function("tool1") mock_registry._swaig_functions = {"tool1": func} result = host.define_tools() assert len(result) == 1 assert result[0] is func - def test_returns_raw_dicts_for_data_map(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_returns_raw_dicts_for_data_map( + self, host: MockToolHost, mock_registry: Mock + ) -> None: data_map = {"function": "dm_func", "data_map": {"url": "https://example.com"}} mock_registry._swaig_functions = {"dm_func": data_map} result = host.define_tools() @@ -214,28 +240,37 @@ def test_returns_mixed_types(self, host: MockToolHost, mock_registry: Mock) -> N # Tests for on_function_call # =========================================================================== + class TestOnFunctionCall: """Tests for ToolMixin.on_function_call""" - def test_unknown_function_returns_error(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_unknown_function_returns_error( + self, host: MockToolHost, mock_registry: Mock + ) -> None: mock_registry._swaig_functions = {} result = host.on_function_call("nonexistent", {}) assert "not found" in result["response"] - def test_data_map_function_returns_error(self, host: MockToolHost, mock_registry: Mock) -> None: - mock_registry._swaig_functions = { - "dm": {"function": "dm", "data_map": {}} - } + def test_data_map_function_returns_error( + self, host: MockToolHost, mock_registry: Mock + ) -> None: + mock_registry._swaig_functions = {"dm": {"function": "dm", "data_map": {}}} result = host.on_function_call("dm", {}) assert "Data map" in result["response"] - def test_webhook_function_returns_error(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_webhook_function_returns_error( + self, host: MockToolHost, mock_registry: Mock + ) -> None: func = _make_swaig_function("webhook_func", webhook_url="https://example.com") mock_registry._swaig_functions = {"webhook_func": func} result = host.on_function_call("webhook_func", {}) - assert "webhook" in result["response"].lower() or "External" in result["response"] + assert ( + "webhook" in result["response"].lower() or "External" in result["response"] + ) - def test_calls_handler_successfully(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_calls_handler_successfully( + self, host: MockToolHost, mock_registry: Mock + ) -> None: expected_result = FunctionResult("success") handler = Mock(return_value=expected_result) func = _make_swaig_function("my_tool", handler=handler) @@ -245,7 +280,9 @@ def test_calls_handler_successfully(self, host: MockToolHost, mock_registry: Moc handler.assert_called_once_with({"key": "val"}, {"raw": "data"}) assert result is expected_result - def test_handler_returning_none_creates_default(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_handler_returning_none_creates_default( + self, host: MockToolHost, mock_registry: Mock + ) -> None: handler = Mock(return_value=None) func = _make_swaig_function("my_tool", handler=handler) mock_registry._swaig_functions = {"my_tool": func} @@ -253,7 +290,9 @@ def test_handler_returning_none_creates_default(self, host: MockToolHost, mock_r result = host.on_function_call("my_tool", {}) assert isinstance(result, FunctionResult) - def test_handler_exception_returns_error(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_handler_exception_returns_error( + self, host: MockToolHost, mock_registry: Mock + ) -> None: handler = Mock(side_effect=RuntimeError("handler crash")) func = _make_swaig_function("my_tool", handler=handler) mock_registry._swaig_functions = {"my_tool": func} @@ -266,6 +305,7 @@ def test_handler_exception_returns_error(self, host: MockToolHost, mock_registry # Tests for _execute_swaig_function # =========================================================================== + class TestExecuteSwaigFunction: """Tests for ToolMixin._execute_swaig_function""" @@ -274,7 +314,9 @@ def test_function_not_found(self, host: MockToolHost, mock_registry: Mock) -> No result = host._execute_swaig_function("nonexistent") assert "error" in result - def test_default_args_when_none(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_default_args_when_none( + self, host: MockToolHost, mock_registry: Mock + ) -> None: handler = Mock(return_value=FunctionResult("done")) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -283,7 +325,9 @@ def test_default_args_when_none(self, host: MockToolHost, mock_registry: Mock) - assert "response" in result assert result["response"] == "done" - def test_passes_args_and_raw_data(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_passes_args_and_raw_data( + self, host: MockToolHost, mock_registry: Mock + ) -> None: handler = Mock(return_value=FunctionResult("ok")) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -301,7 +345,9 @@ def test_with_call_id(self, host: MockToolHost, mock_registry: Mock) -> None: result = host._execute_swaig_function("tool", args={}, call_id="call-42") assert result["response"] == "ok" - def test_constructs_raw_data_with_args(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_constructs_raw_data_with_args( + self, host: MockToolHost, mock_registry: Mock + ) -> None: handler = Mock(return_value=FunctionResult("fine")) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -313,7 +359,9 @@ def test_constructs_raw_data_with_args(self, host: MockToolHost, mock_registry: assert raw_data["function"] == "tool" assert raw_data["call_id"] == "c1" - def test_handler_returning_dict(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_handler_returning_dict( + self, host: MockToolHost, mock_registry: Mock + ) -> None: handler = Mock(return_value={"response": "dict result"}) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -321,7 +369,9 @@ def test_handler_returning_dict(self, host: MockToolHost, mock_registry: Mock) - result = host._execute_swaig_function("tool") assert result["response"] == "dict result" - def test_handler_returning_string(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_handler_returning_string( + self, host: MockToolHost, mock_registry: Mock + ) -> None: handler = Mock(return_value="just a string") func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -329,7 +379,9 @@ def test_handler_returning_string(self, host: MockToolHost, mock_registry: Mock) result = host._execute_swaig_function("tool") assert "response" in result - def test_handler_exception_returns_error_response(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_handler_exception_returns_error_response( + self, host: MockToolHost, mock_registry: Mock + ) -> None: handler = Mock(side_effect=RuntimeError("boom")) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -340,7 +392,9 @@ def test_handler_exception_returns_error_response(self, host: MockToolHost, mock assert "response" in result assert "Error" in result["response"] - def test_empty_args_creates_empty_raw_data_argument(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_empty_args_creates_empty_raw_data_argument( + self, host: MockToolHost, mock_registry: Mock + ) -> None: handler = Mock(return_value=FunctionResult("ok")) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -355,6 +409,7 @@ def test_empty_args_creates_empty_raw_data_argument(self, host: MockToolHost, mo # Tests for _tool_decorator # =========================================================================== + class TestToolDecorator: """Tests for ToolMixin._tool_decorator""" @@ -362,10 +417,16 @@ def test_decorator_returns_callable(self, host: MockToolHost) -> None: decorator = host._tool_decorator(name="test_func") assert callable(decorator) - def test_decorated_function_is_registered(self, host: MockToolHost, mock_registry: Mock) -> None: + def test_decorated_function_is_registered( + self, host: MockToolHost, mock_registry: Mock + ) -> None: mock_registry.define_tool = Mock() - @host._tool_decorator(name="greet", description="Greet user", parameters={"name": {"type": "string"}}) + @host._tool_decorator( + name="greet", + description="Greet user", + parameters={"name": {"type": "string"}}, + ) def greet(args: dict[str, Any], raw_data: dict[str, Any]) -> FunctionResult: return FunctionResult("Hello") @@ -379,13 +440,16 @@ def greet(args: dict[str, Any], raw_data: dict[str, Any]) -> FunctionResult: # Tests for tool class decorator # =========================================================================== + class TestToolClassDecorator: """Tests for ToolMixin.tool class method decorator""" def test_class_decorator_marks_function(self) -> None: decorator = ToolMixin.tool(name="class_func", parameters={}) - def my_func(self: Any, args: dict[str, Any], raw_data: dict[str, Any]) -> FunctionResult: + def my_func( + self: Any, args: dict[str, Any], raw_data: dict[str, Any] + ) -> FunctionResult: return FunctionResult("hi") decorated = decorator(my_func) diff --git a/tests/unit/core/mixins/test_web_mixin.py b/tests/unit/core/mixins/test_web_mixin.py index 2d6d94ec..88d9b39a 100644 --- a/tests/unit/core/mixins/test_web_mixin.py +++ b/tests/unit/core/mixins/test_web_mixin.py @@ -17,13 +17,15 @@ import base64 import asyncio import types -from typing import Any, Awaitable, TypeVar +from typing import Any, TypeVar +from collections.abc import Awaitable from unittest.mock import Mock, patch, MagicMock, AsyncMock from fastapi import FastAPI from signalwire.core.mixins.web_mixin import WebMixin from signalwire.core.function_result import FunctionResult + # SWAIG handler was lifted from WebMixin into SWMLService, with extension # points overridden in AgentBase. Tests in this file historically tested the # monolithic WebMixin handler — we bind the lifted methods onto FakeAgent so @@ -36,6 +38,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_auth_header(username: str, password: str) -> str: """Create a Basic Auth header value.""" encoded = base64.b64encode(f"{username}:{password}".encode()).decode() @@ -57,9 +60,7 @@ def _router_mounted_under(app: FastAPI, prefix: str) -> bool: if p and p.startswith(prefix): return True # Starlette 1.x: a non-leaf container route holds the prefixed sub-router. - return any( - type(r).__name__ in ("_IncludedRouter", "Mount") for r in app.routes - ) + return any(type(r).__name__ in ("_IncludedRouter", "Mount") for r in app.routes) def _make_request( @@ -76,12 +77,16 @@ def _make_request( request.url = Mock() request.url.path = url_path request.query_params = query_params or {} - request.state = Mock(spec=[]) # empty spec so getattr(..., "callback_path", None) returns None + request.state = Mock( + spec=[] + ) # empty spec so getattr(..., "callback_path", None) returns None if body is not None: raw = json.dumps(body).encode() if isinstance(body, dict) else body request.body = AsyncMock(return_value=raw) - request.json = AsyncMock(return_value=body if isinstance(body, dict) else json.loads(body)) + request.json = AsyncMock( + return_value=body if isinstance(body, dict) else json.loads(body) + ) else: request.body = AsyncMock(return_value=b"") request.json = AsyncMock(side_effect=Exception("No body")) @@ -100,27 +105,27 @@ def _build_mixin(**overrides: Any) -> Any: tool_registry = MagicMock() tool_registry._swaig_functions = {} - defaults: dict[str, Any] = dict( - _app=None, - _basic_auth=("user", "pass"), - _proxy_url_base=None, - _proxy_url_base_from_env=False, - _proxy_detection_done=False, - _current_request=None, - _dynamic_config_callback=None, - _is_ephemeral=False, - _suppress_logs=False, - _routing_callbacks={}, - _tool_registry=tool_registry, - _session_manager=MagicMock(), - log=log, - name="test_agent", - route="/agent", - host="0.0.0.0", - port=3000, - ssl_enabled=False, - schema_utils=MagicMock(), - ) + defaults: dict[str, Any] = { + "_app": None, + "_basic_auth": ("user", "pass"), + "_proxy_url_base": None, + "_proxy_url_base_from_env": False, + "_proxy_detection_done": False, + "_current_request": None, + "_dynamic_config_callback": None, + "_is_ephemeral": False, + "_suppress_logs": False, + "_routing_callbacks": {}, + "_tool_registry": tool_registry, + "_session_manager": MagicMock(), + "log": log, + "name": "test_agent", + "route": "/agent", + "host": "0.0.0.0", + "port": 3000, + "ssl_enabled": False, + "schema_utils": MagicMock(), + } defaults.update(overrides) # WebMixin is typed as Any to mypy (its module isn't fully resolvable under @@ -146,7 +151,9 @@ class FakeAgent(WebMixin): if "_find_summary_in_post_data" not in overrides: agent._find_summary_in_post_data = MagicMock(return_value=None) if "get_basic_auth_credentials" not in overrides: - agent.get_basic_auth_credentials = MagicMock(return_value=("user", "pass", "provided")) + agent.get_basic_auth_credentials = MagicMock( + return_value=("user", "pass", "provided") + ) if "get_full_url" not in overrides: agent.get_full_url = MagicMock(return_value="http://localhost:3000/agent") if "_create_ephemeral_copy" not in overrides: @@ -160,19 +167,36 @@ class FakeAgent(WebMixin): # Bind the real implementations so size/content-type tests still trigger # real 413/415 paths. if "_check_content_type" not in overrides: - agent._check_content_type = types.MethodType(_SWMLSvc._check_content_type, agent) + agent._check_content_type = types.MethodType( + _SWMLSvc._check_content_type, agent + ) if "_read_body_with_limit" not in overrides: - agent._read_body_with_limit = types.MethodType(_SWMLSvc._read_body_with_limit, agent) + agent._read_body_with_limit = types.MethodType( + _SWMLSvc._read_body_with_limit, agent + ) # Bind the lifted SWAIG handler + AgentBase extension overrides so tests # of SWAIG behavior (token validation, ephemeral dynamic config) keep # exercising the same path. AgentBase's override IS what these tests # historically asserted on. if "_handle_swaig_request" not in overrides: - agent._handle_swaig_request = types.MethodType(_SWMLSvc._handle_swaig_request, agent) + agent._handle_swaig_request = types.MethodType( + _SWMLSvc._handle_swaig_request, agent + ) if "_swaig_render_get_response" not in overrides: - agent._swaig_render_get_response = types.MethodType(_AgentBase._swaig_render_get_response, agent) + agent._swaig_render_get_response = types.MethodType( + _AgentBase._swaig_render_get_response, agent + ) + # _swaig_pre_dispatch delegates the security decision to the + # transport-agnostic _swaig_validate_token core (shared with the serverless + # modes), so that must be bound too or the delegate resolves to a Mock. + if "_swaig_validate_token" not in overrides: + agent._swaig_validate_token = types.MethodType( + _AgentBase._swaig_validate_token, agent + ) if "_swaig_pre_dispatch" not in overrides: - agent._swaig_pre_dispatch = types.MethodType(_AgentBase._swaig_pre_dispatch, agent) + agent._swaig_pre_dispatch = types.MethodType( + _AgentBase._swaig_pre_dispatch, agent + ) return agent @@ -193,6 +217,7 @@ def _run(coro: Awaitable[_T]) -> _T: # get_app # =========================================================================== + class TestGetApp: """Tests for WebMixin.get_app()""" @@ -248,11 +273,13 @@ def test_get_app_non_root_route_uses_prefix(self) -> None: # as_router # =========================================================================== + class TestAsRouter: """Tests for WebMixin.as_router()""" def test_as_router_returns_api_router(self) -> None: from fastapi import APIRouter + agent = _build_mixin() router = agent.as_router() assert isinstance(router, APIRouter) @@ -280,12 +307,14 @@ def test_as_router_registers_callback_routes(self) -> None: # _register_routes # =========================================================================== + class TestRegisterRoutes: """Tests for WebMixin._register_routes()""" def test_register_routes_creates_slash_variants(self) -> None: agent = _build_mixin() from fastapi import APIRouter + router = APIRouter() agent._register_routes(router) paths = [r.path for r in router.routes if hasattr(r, "path")] @@ -300,6 +329,7 @@ def test_register_routes_skips_root_callback(self) -> None: cb = MagicMock() agent = _build_mixin(_routing_callbacks={"/": cb, "/custom": MagicMock()}) from fastapi import APIRouter + router = APIRouter() agent._register_routes(router) paths = [r.path for r in router.routes if hasattr(r, "path")] @@ -311,6 +341,7 @@ def test_register_routes_skips_root_callback(self) -> None: # Token enforcement / auth in request handlers # =========================================================================== + class TestTokenEnforcement: """Tests for basic auth enforcement across endpoints.""" @@ -372,6 +403,7 @@ def test_check_for_input_rejects_unauthorized(self) -> None: # _handle_root_request # =========================================================================== + class TestHandleRootRequest: """Tests for _handle_root_request.""" @@ -410,7 +442,7 @@ def test_call_id_extracted_from_post_body(self) -> None: request = _make_request("POST", body={"call_id": "cid-xyz"}) _run(agent._handle_root_request(request)) # Verify _render_swml was called with the extracted call_id - args, kwargs = agent._render_swml.call_args + args, _kwargs = agent._render_swml.call_args assert args[0] == "cid-xyz" def test_call_id_extracted_from_nested_call(self) -> None: @@ -418,14 +450,14 @@ def test_call_id_extracted_from_nested_call(self) -> None: body = {"call": {"call_id": "nested-id"}} request = _make_request("POST", body=body) _run(agent._handle_root_request(request)) - args, kwargs = agent._render_swml.call_args + args, _kwargs = agent._render_swml.call_args assert args[0] == "nested-id" def test_call_id_from_query_params_on_get(self) -> None: agent = _build_mixin() request = _make_request("GET", query_params={"call_id": "q-id"}) _run(agent._handle_root_request(request)) - args, kwargs = agent._render_swml.call_args + args, _kwargs = agent._render_swml.call_args assert args[0] == "q-id" def test_proxy_detection_from_forwarded_headers(self) -> None: @@ -516,6 +548,7 @@ def test_render_swml_exception_returns_500(self) -> None: # _handle_debug_request # =========================================================================== + class TestHandleDebugRequest: """Tests for _handle_debug_request.""" @@ -528,16 +561,20 @@ def test_get_returns_swml_with_debug_header(self) -> None: def test_post_extracts_call_id_from_body(self) -> None: agent = _build_mixin() - request = _make_request("POST", body={"call_id": "debug-call-1"}, url_path="/agent/debug") + request = _make_request( + "POST", body={"call_id": "debug-call-1"}, url_path="/agent/debug" + ) _run(agent._handle_debug_request(request)) - args, kwargs = agent._render_swml.call_args + args, _kwargs = agent._render_swml.call_args assert args[0] == "debug-call-1" def test_get_extracts_call_id_from_query(self) -> None: agent = _build_mixin() - request = _make_request("GET", query_params={"call_id": "q-debug"}, url_path="/agent/debug") + request = _make_request( + "GET", query_params={"call_id": "q-debug"}, url_path="/agent/debug" + ) _run(agent._handle_debug_request(request)) - args, kwargs = agent._render_swml.call_args + args, _kwargs = agent._render_swml.call_args assert args[0] == "q-debug" def test_post_malformed_body_still_renders(self) -> None: @@ -566,6 +603,7 @@ def test_on_swml_request_called_with_none_callback(self) -> None: # _handle_swaig_request # =========================================================================== + class TestHandleSwaigRequest: """Tests for _handle_swaig_request.""" @@ -573,7 +611,9 @@ def test_get_returns_swml(self) -> None: agent = _build_mixin() resp = MagicMock() resp.headers = {} - request = _make_request("GET", query_params={"call_id": "c1"}, url_path="/agent/swaig") + request = _make_request( + "GET", query_params={"call_id": "c1"}, url_path="/agent/swaig" + ) response = _run(agent._handle_swaig_request(request, resp)) assert response.status_code == 200 @@ -581,7 +621,9 @@ def test_post_missing_function_name_returns_400(self) -> None: agent = _build_mixin() resp = MagicMock() resp.headers = {} - request = _make_request("POST", body={"no_function": True}, url_path="/agent/swaig") + request = _make_request( + "POST", body={"no_function": True}, url_path="/agent/swaig" + ) response = _run(agent._handle_swaig_request(request, resp)) assert response.status_code == 400 @@ -636,14 +678,20 @@ def test_token_validation_valid(self) -> None: resp.headers = {} body = {"function": "my_func", "call_id": "c1"} request = _make_request( - "POST", body=body, + "POST", + body=body, query_params={"__token": "valid-token"}, - url_path="/agent/swaig" + url_path="/agent/swaig", ) result = _run(agent._handle_swaig_request(request, resp)) - agent._session_manager.validate_tool_token.assert_called_once_with("my_func", "valid-token", "c1") + agent._session_manager.validate_tool_token.assert_called_once_with( + "my_func", "valid-token", "c1" + ) # Function should still be called agent.on_function_call.assert_called() + # A valid token must yield the function's SWAIG result, not an error dict. + assert isinstance(result, dict) + assert "response" in result def test_token_validation_invalid_secure_function_returns_swaig_error(self) -> None: """When a secure function has an invalid token, the handler returns a @@ -657,15 +705,19 @@ def test_token_validation_invalid_secure_function_returns_swaig_error(self) -> N resp.headers = {} body = {"function": "secure_fn", "call_id": "c1"} request = _make_request( - "POST", body=body, + "POST", + body=body, query_params={"token": "bad-token"}, - url_path="/agent/swaig" + url_path="/agent/swaig", ) result = _run(agent._handle_swaig_request(request, resp)) # Should be a plain dict (not an HTTP Response object) assert isinstance(result, dict) assert "response" in result - assert "token" in result["response"].lower() or "invalid" in result["response"].lower() + assert ( + "token" in result["response"].lower() + or "invalid" in result["response"].lower() + ) # Function should NOT have been called agent.on_function_call.assert_not_called() @@ -679,13 +731,16 @@ def test_token_validation_invalid_nonsecure_function_continues(self) -> None: resp.headers = {} body = {"function": "open_fn", "call_id": "c1"} request = _make_request( - "POST", body=body, + "POST", + body=body, query_params={"__token": "bad-token"}, - url_path="/agent/swaig" + url_path="/agent/swaig", ) result = _run(agent._handle_swaig_request(request, resp)) # Should proceed since function is not secure agent.on_function_call.assert_called() + # ...and the caller gets the function's result, not a token error. + assert result == {"response": "allowed"} def test_dynamic_config_callback_creates_ephemeral(self) -> None: ephemeral = MagicMock() @@ -701,6 +756,9 @@ def test_dynamic_config_callback_creates_ephemeral(self) -> None: agent._create_ephemeral_copy.assert_called_once() config_cb.assert_called_once() ephemeral.on_function_call.assert_called_once() + # The response must come from the EPHEMERAL copy — that is the whole + # point of the dynamic-config path. + assert result == {"response": "ephemeral"} def test_function_execution_error_returns_error_dict(self) -> None: agent = _build_mixin() @@ -738,6 +796,7 @@ def test_swaig_function_result_string_wrapped(self) -> None: # _handle_post_prompt_request # =========================================================================== + class TestHandlePostPromptRequest: """Tests for _handle_post_prompt_request.""" @@ -750,7 +809,9 @@ def test_get_returns_swml(self) -> None: def test_post_calls_on_summary(self) -> None: agent = _build_mixin() - agent._find_summary_in_post_data = MagicMock(return_value={"summary": "the call ended"}) + agent._find_summary_in_post_data = MagicMock( + return_value={"summary": "the call ended"} + ) agent.on_summary = MagicMock(return_value=None) body = {"summary": "the call ended", "call_id": "c1"} request = _make_request("POST", body=body, url_path="/agent/post_prompt") @@ -783,24 +844,30 @@ def test_post_token_validation(self) -> None: agent._session_manager.validate_tool_token = MagicMock(return_value=True) body = {"call_id": "c1"} request = _make_request( - "POST", body=body, + "POST", + body=body, query_params={"__token": "good", "call_id": "c1"}, url_path="/agent/post_prompt", ) _run(agent._handle_post_prompt_request(request)) - agent._session_manager.validate_tool_token.assert_called_once_with("post_prompt", "good", "c1") + agent._session_manager.validate_tool_token.assert_called_once_with( + "post_prompt", "good", "c1" + ) def test_post_token_fallback_to_token_param(self) -> None: agent = _build_mixin() agent._session_manager.validate_tool_token = MagicMock(return_value=True) body = {"call_id": "c1"} request = _make_request( - "POST", body=body, + "POST", + body=body, query_params={"token": "fallback-tok", "call_id": "c1"}, url_path="/agent/post_prompt", ) _run(agent._handle_post_prompt_request(request)) - agent._session_manager.validate_tool_token.assert_called_once_with("post_prompt", "fallback-tok", "c1") + agent._session_manager.validate_tool_token.assert_called_once_with( + "post_prompt", "fallback-tok", "c1" + ) def test_post_dynamic_config_creates_ephemeral(self) -> None: ephemeral = MagicMock() @@ -847,6 +914,7 @@ def test_suppress_logs_flag(self) -> None: # _handle_check_for_input_request # =========================================================================== + class TestHandleCheckForInputRequest: """Tests for _handle_check_for_input_request.""" @@ -861,7 +929,11 @@ def test_post_with_conversation_id(self) -> None: def test_get_with_conversation_id(self) -> None: agent = _build_mixin() - request = _make_request("GET", query_params={"conversation_id": "conv-456"}, url_path="/agent/check_for_input") + request = _make_request( + "GET", + query_params={"conversation_id": "conv-456"}, + url_path="/agent/check_for_input", + ) result = _run(agent._handle_check_for_input_request(request)) assert result["status"] == "success" assert result["conversation_id"] == "conv-456" @@ -878,6 +950,7 @@ def test_missing_conversation_id_returns_400(self) -> None: # on_request / on_swml_request # =========================================================================== + class TestOnRequestAndOnSwmlRequest: """Tests for on_request and on_swml_request methods.""" @@ -895,7 +968,9 @@ def test_on_request_returns_none_when_on_swml_request_not_callable(self) -> None result = agent.on_request(None, None) assert result is None - def test_on_swml_request_returns_ephemeral_marker_with_dynamic_callback(self) -> None: + def test_on_swml_request_returns_ephemeral_marker_with_dynamic_callback( + self, + ) -> None: cb = MagicMock() agent = _build_mixin(_dynamic_config_callback=cb) result = agent.on_swml_request({"data": True}, None, None) @@ -926,6 +1001,7 @@ def test_on_swml_request_includes_request_in_marker(self) -> None: # register_routing_callback # =========================================================================== + class TestRegisterRoutingCallback: """Tests for register_routing_callback.""" @@ -963,6 +1039,7 @@ def test_initializes_routing_callbacks_dict(self) -> None: # set_dynamic_config_callback # =========================================================================== + class TestSetDynamicConfigCallback: """Tests for set_dynamic_config_callback.""" @@ -978,6 +1055,7 @@ def test_sets_callback(self) -> None: # manual_set_proxy_url # =========================================================================== + class TestManualSetProxyUrl: """Tests for manual_set_proxy_url.""" @@ -1009,12 +1087,14 @@ def test_none_does_not_set(self) -> None: # setup_graceful_shutdown # =========================================================================== + class TestSetupGracefulShutdown: """Tests for setup_graceful_shutdown.""" def test_registers_signal_handlers(self) -> None: agent = _build_mixin() import signal as sig_module + with patch.object(sig_module, "signal") as mock_signal: agent.setup_graceful_shutdown() calls = mock_signal.call_args_list @@ -1027,6 +1107,7 @@ def test_registers_signal_handlers(self) -> None: # enable_debug_routes # =========================================================================== + class TestEnableDebugRoutes: """Tests for enable_debug_routes.""" @@ -1040,6 +1121,7 @@ def test_returns_self_for_chaining(self) -> None: # Route prefix handling # =========================================================================== + class TestRoutePrefixHandling: """Tests verifying route prefix behaviour with different route configurations.""" @@ -1074,14 +1156,18 @@ def test_serve_root_route(self) -> None: def test_serve_with_prefix(self) -> None: agent = _build_mixin(route="/bot") app = agent.get_app() - # Verify the router was created + # Verify the router was created, and that get_app() hands back that same + # assembled app rather than building a throwaway one. assert agent._app is not None + assert app is agent._app + assert agent.route == "/bot" # =========================================================================== # Azure mode behavior (via run() method) # =========================================================================== + class TestAzureModeBehavior: """Tests for Azure Function mode in the run() method.""" @@ -1090,7 +1176,9 @@ def test_run_azure_function_mode(self) -> None: mock_event = MagicMock() agent.handle_serverless_request = MagicMock(return_value="azure-response") result = agent.run(event=mock_event, context=None, force_mode="azure_function") - agent.handle_serverless_request.assert_called_once_with(mock_event, None, "azure_function") + agent.handle_serverless_request.assert_called_once_with( + mock_event, None, "azure_function" + ) assert result == "azure-response" def test_run_lambda_mode(self) -> None: @@ -1098,7 +1186,9 @@ def test_run_lambda_mode(self) -> None: mock_event = {"headers": {}, "body": "{}"} agent.handle_serverless_request = MagicMock(return_value={"statusCode": 200}) result = agent.run(event=mock_event, context=None, force_mode="lambda") - agent.handle_serverless_request.assert_called_once_with(mock_event, None, "lambda") + agent.handle_serverless_request.assert_called_once_with( + mock_event, None, "lambda" + ) assert result == {"statusCode": 200} def test_run_cgi_mode(self) -> None: @@ -1125,7 +1215,9 @@ def test_run_server_mode_calls_serve(self) -> None: def test_run_lambda_error_returns_500(self) -> None: agent = _build_mixin() - agent.handle_serverless_request = MagicMock(side_effect=RuntimeError("lambda fail")) + agent.handle_serverless_request = MagicMock( + side_effect=RuntimeError("lambda fail") + ) result = agent.run(force_mode="lambda") assert result["statusCode"] == 500 body = json.loads(result["body"]) @@ -1133,15 +1225,21 @@ def test_run_lambda_error_returns_500(self) -> None: def test_run_non_lambda_error_raises(self) -> None: agent = _build_mixin() - agent.handle_serverless_request = MagicMock(side_effect=RuntimeError("cgi fail")) - with pytest.raises(RuntimeError, match="cgi fail"): - with patch("builtins.print"): - agent.run(force_mode="cgi") + agent.handle_serverless_request = MagicMock( + side_effect=RuntimeError("cgi fail") + ) + with ( + pytest.raises(RuntimeError, match="cgi fail"), + patch("builtins.print"), + ): + agent.run(force_mode="cgi") def test_run_auto_detection_defaults_to_server(self) -> None: agent = _build_mixin() agent.serve = MagicMock() - with patch("signalwire.core.mixins.web_mixin.get_execution_mode", return_value="server"): + with patch( + "signalwire.core.mixins.web_mixin.get_execution_mode", return_value="server" + ): agent.run() agent.serve.assert_called_once() @@ -1150,6 +1248,7 @@ def test_run_auto_detection_defaults_to_server(self) -> None: # serve() method # =========================================================================== + class TestServe: """Tests for serve() method.""" @@ -1159,6 +1258,7 @@ def _patch_uvicorn(self) -> Any: def test_serve_uses_default_host_and_port(self) -> None: import sys + mock_uvicorn = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): agent = _build_mixin(host="0.0.0.0", port=3000) @@ -1170,6 +1270,7 @@ def test_serve_uses_default_host_and_port(self) -> None: def test_serve_uses_override_host_and_port(self) -> None: import sys + mock_uvicorn = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): agent = _build_mixin(host="0.0.0.0", port=3000) @@ -1180,6 +1281,7 @@ def test_serve_uses_override_host_and_port(self) -> None: def test_serve_with_ssl(self) -> None: import sys + mock_uvicorn = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): agent = _build_mixin( @@ -1194,6 +1296,7 @@ def test_serve_with_ssl(self) -> None: def test_serve_without_ssl(self) -> None: import sys + mock_uvicorn = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): agent = _build_mixin(ssl_enabled=False) @@ -1204,6 +1307,7 @@ def test_serve_without_ssl(self) -> None: def test_serve_caches_app(self) -> None: import sys + mock_uvicorn = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): agent = _build_mixin() @@ -1212,6 +1316,7 @@ def test_serve_caches_app(self) -> None: def test_serve_reuses_cached_app(self) -> None: import sys + mock_uvicorn = MagicMock() fake_app = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): @@ -1225,6 +1330,7 @@ def test_serve_reuses_cached_app(self) -> None: def test_serve_root_route_includes_router_without_prefix(self) -> None: import sys + mock_uvicorn = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): agent = _build_mixin(route="/") @@ -1236,6 +1342,7 @@ def test_serve_root_route_includes_router_without_prefix(self) -> None: # Additional coverage tests # =========================================================================== + class TestHandleRootRequestModifications: """Tests for on_swml_request modification paths in _handle_root_request.""" @@ -1246,7 +1353,7 @@ def test_on_swml_request_returns_truthy_modifications(self) -> None: agent.on_swml_request = MagicMock(return_value=mods) request = _make_request("POST", body={"call_id": "c1"}) _run(agent._handle_root_request(request)) - args, kwargs = agent._render_swml.call_args + args, _kwargs = agent._render_swml.call_args assert args[1] == mods def test_on_swml_request_exception_handled(self) -> None: @@ -1311,6 +1418,9 @@ def test_dynamic_config_callback_error_still_calls_function(self) -> None: result = _run(agent._handle_swaig_request(request, resp)) # Function should still be called on the ephemeral copy ephemeral.on_function_call.assert_called_once() + # ...and the caller still gets its result — the config-callback failure + # is logged, not surfaced as an error response. + assert result == {"response": "ok"} class TestHandlePostPromptRequestExtraPaths: @@ -1341,12 +1451,15 @@ def test_invalid_token_with_debug_token(self) -> None: """Lines 834-840: invalid token triggers debug_token call.""" agent = _build_mixin() agent._session_manager.validate_tool_token = MagicMock(return_value=False) - agent._session_manager.debug_token = MagicMock(return_value={"reason": "expired"}) + agent._session_manager.debug_token = MagicMock( + return_value={"reason": "expired"} + ) agent._find_summary_in_post_data = MagicMock(return_value=None) agent.on_summary = MagicMock(return_value=None) body = {"call_id": "c1"} request = _make_request( - "POST", body=body, + "POST", + body=body, query_params={"__token": "bad-tok", "call_id": "c1"}, url_path="/agent/post_prompt", ) @@ -1357,12 +1470,15 @@ def test_invalid_token_with_debug_token(self) -> None: def test_token_validation_error(self) -> None: """Line 839-840: exception during token validation is caught.""" agent = _build_mixin() - agent._session_manager.validate_tool_token = MagicMock(side_effect=RuntimeError("token err")) + agent._session_manager.validate_tool_token = MagicMock( + side_effect=RuntimeError("token err") + ) agent._find_summary_in_post_data = MagicMock(return_value=None) agent.on_summary = MagicMock(return_value=None) body = {"call_id": "c1"} request = _make_request( - "POST", body=body, + "POST", + body=body, query_params={"__token": "tok", "call_id": "c1"}, url_path="/agent/post_prompt", ) @@ -1397,7 +1513,9 @@ def test_post_body_parsing_error_results_in_empty_body(self) -> None: agent._find_summary_in_post_data = MagicMock(return_value=None) agent.on_summary = MagicMock(return_value=None) # Use a Mock with spec to prevent auto-attribute creation - request = Mock(spec=["method", "headers", "url", "query_params", "state", "body", "json"]) + request = Mock( + spec=["method", "headers", "url", "query_params", "state", "body", "json"] + ) request.method = "POST" request.headers = {} request.url = Mock() @@ -1458,7 +1576,11 @@ def test_general_exception_returns_500(self) -> None: agent = _build_mixin() # Make _check_basic_auth raise to trigger the outer exception handler agent._check_basic_auth = MagicMock(side_effect=RuntimeError("unexpected")) - request = _make_request("GET", query_params={"conversation_id": "c1"}, url_path="/agent/check_for_input") + request = _make_request( + "GET", + query_params={"conversation_id": "c1"}, + url_path="/agent/check_for_input", + ) response = _run(agent._handle_check_for_input_request(request)) assert response.status_code == 500 body = json.loads(response.body) @@ -1492,6 +1614,7 @@ def test_signal_handler_cleanup_error(self) -> None: # Make the log.info raise during "cleanup_completed" to trigger the except branch call_count = [0] original_info = agent.log.info + def info_side_effect(*args: Any, **kwargs: Any) -> Any: call_count[0] += 1 if call_count[0] == 2: # second log.info call is "cleanup_completed" @@ -1520,6 +1643,7 @@ class TestGetAppEndpointsViaTestClient: def test_health_endpoint(self) -> None: """Lines 54: health endpoint returns healthy status.""" from starlette.testclient import TestClient + agent = _build_mixin() app = agent.get_app() client = TestClient(app) @@ -1532,6 +1656,7 @@ def test_health_endpoint(self) -> None: def test_ready_endpoint(self) -> None: """Line 65: ready endpoint returns ready status.""" from starlette.testclient import TestClient + agent = _build_mixin() app = agent.get_app() client = TestClient(app) @@ -1546,6 +1671,7 @@ class TestRootRequestProxyParentDetection: def test_no_proxy_headers_calls_parent_detect_proxy(self) -> None: """Lines 452-457: when parent has _detect_proxy_from_request, it is called.""" + class FakeParent: def __init__(self) -> None: self._proxy_url_base: str | None = None @@ -1602,20 +1728,27 @@ def __init__(self) -> None: # Security audit tests # =========================================================================== + class TestSecurityBodySizeLimit: """Test request body size limit enforcement (413).""" def test_oversized_body_returns_413_root(self) -> None: agent = _build_mixin() # Simulate a request with Content-Length > 10MB - headers = {"content-length": str(11 * 1024 * 1024), "content-type": "application/json"} + headers = { + "content-length": str(11 * 1024 * 1024), + "content-type": "application/json", + } request = _make_request("POST", headers=headers, body={"key": "value"}) response = _run(agent._handle_root_request(request)) assert response.status_code == 413 def test_oversized_body_returns_413_swaig(self) -> None: agent = _build_mixin() - headers = {"content-length": str(11 * 1024 * 1024), "content-type": "application/json"} + headers = { + "content-length": str(11 * 1024 * 1024), + "content-type": "application/json", + } request = _make_request("POST", headers=headers, body={"function": "test"}) response_obj = MagicMock() response_obj.headers = {} @@ -1624,28 +1757,42 @@ def test_oversized_body_returns_413_swaig(self) -> None: def test_oversized_body_returns_413_debug(self) -> None: agent = _build_mixin() - headers = {"content-length": str(11 * 1024 * 1024), "content-type": "application/json"} + headers = { + "content-length": str(11 * 1024 * 1024), + "content-type": "application/json", + } request = _make_request("POST", headers=headers, body={}) response = _run(agent._handle_debug_request(request)) assert response.status_code == 413 def test_oversized_body_returns_413_post_prompt(self) -> None: agent = _build_mixin() - headers = {"content-length": str(11 * 1024 * 1024), "content-type": "application/json"} + headers = { + "content-length": str(11 * 1024 * 1024), + "content-type": "application/json", + } request = _make_request("POST", headers=headers, body={"summary": "x"}) response = _run(agent._handle_post_prompt_request(request)) assert response.status_code == 413 def test_oversized_body_returns_413_check_for_input(self) -> None: agent = _build_mixin() - headers = {"content-length": str(11 * 1024 * 1024), "content-type": "application/json"} - request = _make_request("POST", headers=headers, body={"conversation_id": "abc"}) + headers = { + "content-length": str(11 * 1024 * 1024), + "content-type": "application/json", + } + request = _make_request( + "POST", headers=headers, body={"conversation_id": "abc"} + ) response = _run(agent._handle_check_for_input_request(request)) assert response.status_code == 413 def test_oversized_body_returns_413_debug_events(self) -> None: agent = _build_mixin() - headers = {"content-length": str(11 * 1024 * 1024), "content-type": "application/json"} + headers = { + "content-length": str(11 * 1024 * 1024), + "content-type": "application/json", + } request = _make_request("POST", headers=headers, body={"label": "test"}) response = _run(agent._handle_debug_events_request(request)) assert response.status_code == 413 @@ -1698,8 +1845,9 @@ class TestSecurityFunctionNameValidation: def test_invalid_function_name_returns_400(self) -> None: agent = _build_mixin() headers = {"content-type": "application/json"} - request = _make_request("POST", headers=headers, - body={"function": "../etc/passwd"}) + request = _make_request( + "POST", headers=headers, body={"function": "../etc/passwd"} + ) response_obj = MagicMock() response_obj.headers = {} response = _run(agent._handle_swaig_request(request, response_obj)) @@ -1708,8 +1856,9 @@ def test_invalid_function_name_returns_400(self) -> None: def test_function_name_with_spaces_returns_400(self) -> None: agent = _build_mixin() headers = {"content-type": "application/json"} - request = _make_request("POST", headers=headers, - body={"function": "my function"}) + request = _make_request( + "POST", headers=headers, body={"function": "my function"} + ) response_obj = MagicMock() response_obj.headers = {} response = _run(agent._handle_swaig_request(request, response_obj)) @@ -1717,15 +1866,18 @@ def test_function_name_with_spaces_returns_400(self) -> None: def test_valid_function_name_passes(self) -> None: agent = _build_mixin() - agent._tool_registry._swaig_functions = {"get_balance": {"handler": MagicMock()}} + agent._tool_registry._swaig_functions = { + "get_balance": {"handler": MagicMock()} + } headers = {"content-type": "application/json"} - request = _make_request("POST", headers=headers, - body={"function": "get_balance", "argument": {}}) + request = _make_request( + "POST", headers=headers, body={"function": "get_balance", "argument": {}} + ) response_obj = MagicMock() response_obj.headers = {} result = _run(agent._handle_swaig_request(request, response_obj)) # Should not be a 400 error - if hasattr(result, 'status_code'): + if hasattr(result, "status_code"): assert result.status_code != 400 @@ -1738,6 +1890,7 @@ def test_cors_credentials_false(self) -> None: # Check that CORS middleware was added with allow_credentials=False # We check the middleware stack from starlette.middleware.cors import CORSMiddleware as StarletteCORS + for middleware in app.user_middleware: if middleware.cls is StarletteCORS: assert middleware.kwargs.get("allow_credentials") is False @@ -1751,13 +1904,16 @@ class TestSecurityHeaders: def test_security_headers_present_via_get_app(self) -> None: from starlette.testclient import TestClient + agent = _build_mixin(route="/") app = agent.get_app() client = TestClient(app) response = client.get("/health") assert response.headers.get("X-Content-Type-Options") == "nosniff" assert response.headers.get("X-Frame-Options") == "DENY" - assert response.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin" + assert ( + response.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin" + ) class TestSecurityDebugGuard: @@ -1793,7 +1949,9 @@ def test_malformed_host_rejected(self) -> None: with patch.dict(os.environ, {"SWML_TRUST_PROXY_HEADERS": "true"}): _run(agent._handle_root_request(request)) # proxy should NOT have been set - assert agent._proxy_url_base is None or "DROP TABLE" not in str(agent._proxy_url_base) + assert agent._proxy_url_base is None or "DROP TABLE" not in str( + agent._proxy_url_base + ) def test_invalid_proto_rejected(self) -> None: agent = _build_mixin() @@ -1825,6 +1983,7 @@ class TestSessionManagerDebugGuard: def test_debug_token_disabled_by_default(self) -> None: from signalwire.core.security.session_manager import SessionManager + manager = SessionManager() token = manager.generate_token("func", "call_123") result = manager.debug_token(token) @@ -1832,6 +1991,7 @@ def test_debug_token_disabled_by_default(self) -> None: def test_debug_token_enabled(self) -> None: from signalwire.core.security.session_manager import SessionManager + manager = SessionManager() manager._debug_mode = True token = manager.generate_token("func", "call_123") diff --git a/tests/unit/core/test_agent_base.py b/tests/unit/core/test_agent_base.py index d51e22ed..c8115365 100644 --- a/tests/unit/core/test_agent_base.py +++ b/tests/unit/core/test_agent_base.py @@ -15,14 +15,13 @@ import json import uuid import os -from unittest.mock import Mock, patch, MagicMock, AsyncMock -from typing import Dict, Any, List, Optional +from unittest.mock import Mock, patch +from typing import Any, ClassVar from signalwire.core.agent_base import AgentBase from signalwire.core.swaig_function import SWAIGFunction - class TestAgentBaseInitialization: """Test AgentBase initialization""" @@ -30,8 +29,7 @@ def _create_mock_agent(self, **kwargs: Any) -> AgentBase: """Helper to create a properly mocked agent""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) - agent = AgentBase(schema_validation=False, **kwargs) - return agent + return AgentBase(schema_validation=False, **kwargs) def test_basic_initialization(self) -> None: """Test basic AgentBase initialization""" @@ -56,7 +54,7 @@ def test_initialization_with_custom_params(self) -> None: auto_answer=False, record_call=True, agent_id="custom-id", - native_functions=["func1", "func2"] + native_functions=["func1", "func2"], ) assert agent.get_name() == "custom_agent" @@ -67,6 +65,7 @@ def test_initialization_with_custom_params(self) -> None: assert agent.pom is None assert agent.native_functions == ["func1", "func2"] + class TestAgentBasePromptMethods: """Test AgentBase prompt-related methods""" @@ -261,18 +260,18 @@ def setup_method(self) -> None: def test_define_tool(self) -> None: """Test defining a tool""" + def test_handler(arg1: str, arg2: int) -> str: return f"{arg1}_{arg2}" parameters = { "type": "object", - "properties": { - "arg1": {"type": "string"}, - "arg2": {"type": "integer"} - } + "properties": {"arg1": {"type": "string"}, "arg2": {"type": "integer"}}, } - result = self.agent.define_tool("test_tool", "Test description", parameters, test_handler) + result = self.agent.define_tool( + "test_tool", "Test description", parameters, test_handler + ) assert result is self.agent assert "test_tool" in self.agent._tool_registry._swaig_functions @@ -323,7 +322,9 @@ def setup_method(self) -> None: """Set up test fixtures""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) - self.agent = AgentBase("test_agent", basic_auth=("user", "pass"), schema_validation=False) + self.agent = AgentBase( + "test_agent", basic_auth=("user", "pass"), schema_validation=False + ) def test_validate_basic_auth_success(self) -> None: """Test successful basic auth validation""" @@ -357,7 +358,9 @@ def test_get_basic_auth_credentials(self) -> None: def test_get_basic_auth_credentials_with_source(self) -> None: """Test getting basic auth credentials with source""" - username, password, source = self.agent.get_basic_auth_credentials(include_source=True) # type: ignore[misc] # include_source=True returns 3-tuple + username, password, source = self.agent.get_basic_auth_credentials( + include_source=True + ) # type: ignore[misc] # include_source=True returns 3-tuple assert username == "user" assert password == "pass" @@ -371,7 +374,13 @@ def setup_method(self) -> None: """Set up test fixtures""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) - self.agent = AgentBase("test_agent", host="localhost", port=3000, route="/test", schema_validation=False) + self.agent = AgentBase( + "test_agent", + host="localhost", + port=3000, + route="/test", + schema_validation=False, + ) def test_get_full_url_basic(self) -> None: """Test getting full URL without auth""" @@ -383,8 +392,14 @@ def test_get_full_url_with_auth(self) -> None: """Test getting full URL with auth""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) - agent = AgentBase("test_agent", host="localhost", port=3000, route="/test", - basic_auth=("user", "pass"), schema_validation=False) + agent = AgentBase( + "test_agent", + host="localhost", + port=3000, + route="/test", + basic_auth=("user", "pass"), + schema_validation=False, + ) url = agent.get_full_url(include_auth=True) @@ -432,18 +447,25 @@ def test_add_skill(self) -> None: result = self.agent.add_skill("test_skill", {"param": "value"}) assert result is self.agent - self.mock_skill_manager_instance.load_skill.assert_called_once_with("test_skill", params={"param": "value"}) + self.mock_skill_manager_instance.load_skill.assert_called_once_with( + "test_skill", params={"param": "value"} + ) def test_remove_skill(self) -> None: """Test removing a skill""" result = self.agent.remove_skill("test_skill") assert result is self.agent - self.mock_skill_manager_instance.unload_skill.assert_called_once_with("test_skill") + self.mock_skill_manager_instance.unload_skill.assert_called_once_with( + "test_skill" + ) def test_list_skills(self) -> None: """Test listing skills""" - self.mock_skill_manager_instance.list_loaded_skills.return_value = ["skill1", "skill2"] + self.mock_skill_manager_instance.list_loaded_skills.return_value = [ + "skill1", + "skill2", + ] result = self.agent.list_skills() @@ -480,7 +502,9 @@ def test_create_tool_token(self) -> None: token = self.agent._create_tool_token("test_tool", "call_123") assert token == "test_token" - self.mock_session_manager_instance.create_tool_token.assert_called_once_with("test_tool", "call_123") + self.mock_session_manager_instance.create_tool_token.assert_called_once_with( + "test_tool", "call_123" + ) def test_validate_tool_token(self) -> None: """Test validating tool token""" @@ -494,7 +518,9 @@ def test_validate_tool_token(self) -> None: result = self.agent.validate_tool_token("test_tool", "test_token", "call_123") assert result is True - self.mock_session_manager_instance.validate_tool_token.assert_called_once_with("test_tool", "test_token", "call_123") + self.mock_session_manager_instance.validate_tool_token.assert_called_once_with( + "test_tool", "test_token", "call_123" + ) class TestAgentBaseMiscMethods: @@ -514,7 +540,10 @@ def test_get_name(self) -> None: def test_set_dynamic_config_callback(self) -> None: """Test setting dynamic config callback""" - def callback(request_data: Any, call_data: Any, meta_data: Any, config: Any) -> None: + + def callback( + request_data: Any, call_data: Any, meta_data: Any, config: Any + ) -> None: pass result = self.agent.set_dynamic_config_callback(callback) @@ -545,34 +574,36 @@ class TestAgentBaseDeclarativePrompts: def test_process_prompt_sections_dict(self) -> None: """Test processing declarative prompt sections from dict""" + class TestAgent(AgentBase): - PROMPT_SECTIONS = { # type: ignore[assignment] # base declares None; subclass overrides with dict + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = { "Instructions": "Follow these rules", "Rules": ["Rule 1", "Rule 2"], "Complex": { "body": "Complex section", "bullets": ["Bullet 1", "Bullet 2"], - "numbered": True - } + "numbered": True, + }, } with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) - with patch.object(TestAgent, 'prompt_add_section') as mock_add_section: - agent = TestAgent("test_agent", schema_validation=False) + with patch.object(TestAgent, "prompt_add_section") as mock_add_section: + TestAgent("test_agent", schema_validation=False) # Should have called prompt_add_section for each section assert mock_add_section.call_count == 3 def test_process_prompt_sections_no_pom(self) -> None: """Test processing prompt sections when POM is disabled""" + class TestAgent(AgentBase): - PROMPT_SECTIONS = {"Test": "Content"} # type: ignore[assignment] # base declares None; subclass overrides with dict + PROMPT_SECTIONS: ClassVar[dict[str, Any]] = {"Test": "Content"} with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) - with patch.object(TestAgent, 'prompt_add_section') as mock_add_section: - agent = TestAgent("test_agent", use_pom=False, schema_validation=False) + with patch.object(TestAgent, "prompt_add_section") as mock_add_section: + TestAgent("test_agent", use_pom=False, schema_validation=False) # Should not call prompt_add_section when POM is disabled mock_add_section.assert_not_called() @@ -587,8 +618,7 @@ def _make_agent(**kwargs: Any) -> AgentBase: """Module-level helper to create a properly mocked agent.""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) - agent = AgentBase(schema_validation=False, **kwargs) - return agent + return AgentBase(schema_validation=False, **kwargs) class TestRenderSwml: @@ -620,28 +650,28 @@ def test_render_swml_contains_answer_verb(self) -> None: agent = self._make() doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] + verb_names = [next(iter(v.keys())) for v in verbs if isinstance(v, dict)] assert "answer" in verb_names def test_render_swml_contains_ai_verb(self) -> None: agent = self._make() doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] + verb_names = [next(iter(v.keys())) for v in verbs if isinstance(v, dict)] assert "ai" in verb_names def test_render_swml_answer_before_ai(self) -> None: agent = self._make() doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] + verb_names = [next(iter(v.keys())) for v in verbs if isinstance(v, dict)] assert verb_names.index("answer") < verb_names.index("ai") def test_render_swml_no_answer_when_auto_answer_false(self) -> None: agent = self._make(auto_answer=False) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] + verb_names = [next(iter(v.keys())) for v in verbs if isinstance(v, dict)] assert "answer" not in verb_names def test_render_swml_ai_section_has_prompt(self) -> None: @@ -649,7 +679,7 @@ def test_render_swml_ai_section_has_prompt(self) -> None: agent.set_prompt_text("Hello world") doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - ai_verb = [v for v in verbs if isinstance(v, dict) and "ai" in v][0] + ai_verb = next(v for v in verbs if isinstance(v, dict) and "ai" in v) ai_config = ai_verb["ai"] assert "prompt" in ai_config @@ -657,7 +687,9 @@ def test_render_swml_ai_prompt_text(self) -> None: agent = self._make() agent.set_prompt_text("Be a helpful agent") doc = json.loads(agent._render_swml()) - ai_verb = [v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v][0] + ai_verb = next( + v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v + ) prompt = ai_verb["ai"]["prompt"] if isinstance(prompt, dict): assert "Be a helpful agent" in prompt.get("text", "") @@ -668,7 +700,9 @@ def test_render_swml_with_post_prompt(self) -> None: agent = self._make() agent.set_post_prompt("Summarize the conversation") doc = json.loads(agent._render_swml()) - ai_verb = [v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v][0] + ai_verb = next( + v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v + ) ai_config = ai_verb["ai"] assert "post_prompt" in ai_config @@ -676,7 +710,9 @@ def test_render_swml_with_hints(self) -> None: agent = self._make() agent.add_hints(["hint1", "hint2"]) doc = json.loads(agent._render_swml()) - ai_verb = [v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v][0] + ai_verb = next( + v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v + ) assert "hints" in ai_verb["ai"] assert "hint1" in ai_verb["ai"]["hints"] assert "hint2" in ai_verb["ai"]["hints"] @@ -685,7 +721,9 @@ def test_render_swml_with_languages(self) -> None: agent = self._make() agent.add_language("English", "en", "alice") doc = json.loads(agent._render_swml()) - ai_verb = [v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v][0] + ai_verb = next( + v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v + ) assert "languages" in ai_verb["ai"] assert ai_verb["ai"]["languages"][0]["name"] == "English" @@ -693,7 +731,9 @@ def test_render_swml_with_params(self) -> None: agent = self._make() agent.set_params({"temperature": 0.5}) doc = json.loads(agent._render_swml()) - ai_verb = [v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v][0] + ai_verb = next( + v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v + ) assert "params" in ai_verb["ai"] assert ai_verb["ai"]["params"]["temperature"] == 0.5 @@ -701,7 +741,9 @@ def test_render_swml_with_global_data(self) -> None: agent = self._make() agent.set_global_data({"key": "value"}) doc = json.loads(agent._render_swml()) - ai_verb = [v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v][0] + ai_verb = next( + v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v + ) assert "global_data" in ai_verb["ai"] assert ai_verb["ai"]["global_data"]["key"] == "value" @@ -709,7 +751,9 @@ def test_render_swml_with_pronunciation(self) -> None: agent = self._make() agent.add_pronunciation("SQL", "sequel") doc = json.loads(agent._render_swml()) - ai_verb = [v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v][0] + ai_verb = next( + v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v + ) assert "pronounce" in ai_verb["ai"] assert ai_verb["ai"]["pronounce"][0]["replace"] == "SQL" @@ -717,28 +761,32 @@ def test_render_swml_with_record_call(self) -> None: agent = self._make(record_call=True) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] + verb_names = [next(iter(v.keys())) for v in verbs if isinstance(v, dict)] assert "record_call" in verb_names def test_render_swml_record_call_before_ai(self) -> None: agent = self._make(record_call=True) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] + verb_names = [next(iter(v.keys())) for v in verbs if isinstance(v, dict)] assert verb_names.index("record_call") < verb_names.index("ai") def test_render_swml_record_call_format(self) -> None: agent = self._make(record_call=True, record_format="wav", record_stereo=False) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - record_verb = [v for v in verbs if isinstance(v, dict) and "record_call" in v][0] + record_verb = next( + v for v in verbs if isinstance(v, dict) and "record_call" in v + ) assert record_verb["record_call"]["format"] == "wav" assert record_verb["record_call"]["stereo"] is False def test_render_swml_with_native_functions(self) -> None: agent = self._make(native_functions=["transfer", "check_time"]) doc = json.loads(agent._render_swml()) - ai_verb = [v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v][0] + ai_verb = next( + v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v + ) swaig = ai_verb["ai"].get("SWAIG", {}) assert "native_functions" in swaig assert "transfer" in swaig["native_functions"] @@ -747,7 +795,9 @@ def test_render_swml_with_function_includes(self) -> None: agent = self._make() agent.add_function_include("http://example.com/funcs", ["fn1"]) doc = json.loads(agent._render_swml()) - ai_verb = [v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v][0] + ai_verb = next( + v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v + ) swaig = ai_verb["ai"].get("SWAIG", {}) assert "includes" in swaig assert swaig["includes"][0]["url"] == "http://example.com/funcs" @@ -767,7 +817,7 @@ def test_ephemeral_copy_returns_agent(self) -> None: def test_ephemeral_copy_is_marked_ephemeral(self) -> None: agent = self._make() copy = agent._create_ephemeral_copy() - assert getattr(copy, '_is_ephemeral', False) is True + assert getattr(copy, "_is_ephemeral", False) is True def test_ephemeral_copy_has_same_name(self) -> None: agent = self._make() @@ -813,7 +863,9 @@ def test_ephemeral_function_includes_independent(self) -> None: agent = self._make() agent.add_function_include("http://example.com", ["fn1"]) copy = agent._create_ephemeral_copy() - copy._function_includes.append({"url": "http://other.com", "functions": ["fn2"]}) + copy._function_includes.append( + {"url": "http://other.com", "functions": ["fn2"]} + ) assert len(agent._function_includes) == 1 def test_ephemeral_pre_answer_verbs_independent(self) -> None: @@ -926,7 +978,9 @@ def test_add_pre_answer_verb_transfer(self) -> None: def test_add_pre_answer_verb_connect_auto_answer_false(self) -> None: agent = self._make() - agent.add_pre_answer_verb("connect", {"from": "+15551234567", "auto_answer": False}) + agent.add_pre_answer_verb( + "connect", {"from": "+15551234567", "auto_answer": False} + ) assert agent._pre_answer_verbs[0][0] == "connect" # -- post-answer verbs -- @@ -955,7 +1009,9 @@ def test_add_post_ai_verb_basic(self) -> None: def test_add_post_ai_verb_multiple(self) -> None: agent = self._make() - agent.add_post_ai_verb("request", {"url": "http://api.com/log", "method": "POST"}) + agent.add_post_ai_verb( + "request", {"url": "http://api.com/log", "method": "POST"} + ) agent.add_post_ai_verb("hangup", {}) assert len(agent._post_ai_verbs) == 2 @@ -989,7 +1045,7 @@ def test_pre_answer_verbs_before_answer_in_swml(self) -> None: agent.add_pre_answer_verb("sleep", {"time": 500}) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] + verb_names = [next(iter(v.keys())) for v in verbs if isinstance(v, dict)] assert verb_names.index("sleep") < verb_names.index("answer") def test_post_answer_verbs_between_answer_and_ai(self) -> None: @@ -997,7 +1053,7 @@ def test_post_answer_verbs_between_answer_and_ai(self) -> None: agent.add_post_answer_verb("play", {"url": "say:Welcome"}) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] + verb_names = [next(iter(v.keys())) for v in verbs if isinstance(v, dict)] answer_idx = verb_names.index("answer") play_idx = verb_names.index("play") ai_idx = verb_names.index("ai") @@ -1008,7 +1064,7 @@ def test_post_ai_verbs_after_ai(self) -> None: agent.add_post_ai_verb("hangup", {}) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] + verb_names = [next(iter(v.keys())) for v in verbs if isinstance(v, dict)] assert verb_names.index("ai") < verb_names.index("hangup") def test_full_verb_ordering(self) -> None: @@ -1019,7 +1075,7 @@ def test_full_verb_ordering(self) -> None: agent.add_post_ai_verb("hangup", {}) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] + verb_names = [next(iter(v.keys())) for v in verbs if isinstance(v, dict)] # sleep < answer < record_call < play < ai < hangup assert verb_names.index("sleep") < verb_names.index("answer") assert verb_names.index("answer") < verb_names.index("record_call") @@ -1043,7 +1099,7 @@ def test_answer_verb_config_in_swml(self) -> None: agent.add_answer_verb({"max_duration": 3600}) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] - answer_verb = [v for v in verbs if isinstance(v, dict) and "answer" in v][0] + answer_verb = next(v for v in verbs if isinstance(v, dict) and "answer" in v) assert answer_verb["answer"]["max_duration"] == 3600 @@ -1104,7 +1160,9 @@ def test_auto_map_short_name_no_vowel_variant(self) -> None: agent.auto_map_sip_usernames() assert "abc" in agent._sip_usernames # Should not have a no-vowels variant for short names - assert len([u for u in agent._sip_usernames if u != "abc"]) <= 1 # only route variant + assert ( + len([u for u in agent._sip_usernames if u != "abc"]) <= 1 + ) # only route variant def test_enable_sip_routing_returns_self(self) -> None: agent = self._make() @@ -1115,14 +1173,14 @@ def test_enable_sip_routing_auto_map_true(self) -> None: agent = self._make() agent.enable_sip_routing(auto_map=True) # Should have registered at least the agent name - assert hasattr(agent, '_sip_usernames') + assert hasattr(agent, "_sip_usernames") assert "sip_test" in agent._sip_usernames def test_enable_sip_routing_auto_map_false(self) -> None: agent = self._make() agent.enable_sip_routing(auto_map=False) # With auto_map=False, _sip_usernames might not be populated - usernames: set[str] = getattr(agent, '_sip_usernames', set()) + usernames: set[str] = getattr(agent, "_sip_usernames", set()) # Should NOT have auto-mapped the agent name assert "sip_test" not in usernames @@ -1144,16 +1202,24 @@ def test_server_mode_basic_url(self) -> None: def test_server_mode_with_auth(self) -> None: agent = _make_agent( - name="url_test", host="localhost", port=3000, - route="/test", basic_auth=("user", "pass"), use_pom=False + name="url_test", + host="localhost", + port=3000, + route="/test", + basic_auth=("user", "pass"), + use_pom=False, ) url = agent.get_full_url(include_auth=True) assert "user:pass@" in url def test_server_mode_without_auth(self) -> None: agent = _make_agent( - name="url_test", host="localhost", port=3000, - route="/test", basic_auth=("user", "pass"), use_pom=False + name="url_test", + host="localhost", + port=3000, + route="/test", + basic_auth=("user", "pass"), + use_pom=False, ) url = agent.get_full_url(include_auth=False) assert "user:pass@" not in url @@ -1168,8 +1234,11 @@ def test_proxy_url_takes_precedence(self) -> None: def test_proxy_url_with_auth(self) -> None: agent = _make_agent( - name="url_test", host="localhost", port=3000, - basic_auth=("u", "p"), use_pom=False + name="url_test", + host="localhost", + port=3000, + basic_auth=("u", "p"), + use_pom=False, ) agent._proxy_url_base = "https://proxy.example.com/agent" url = agent.get_full_url(include_auth=True) @@ -1345,6 +1414,7 @@ def _make(self, **kw: Any) -> AgentBase: def test_define_tool_basic(self) -> None: agent = self._make() + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} @@ -1352,22 +1422,26 @@ def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: "my_tool", "A test tool", {"type": "object", "properties": {"name": {"type": "string"}}}, - handler + handler, ) assert result is agent assert "my_tool" in agent._tool_registry._swaig_functions def test_define_tool_with_required(self) -> None: agent = self._make() + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} agent.define_tool( "req_tool", "Tool with required params", - {"type": "object", "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}}, + { + "type": "object", + "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, + }, handler, - required=["name"] + required=["name"], ) func = agent._tool_registry._swaig_functions["req_tool"] assert isinstance(func, SWAIGFunction) @@ -1375,6 +1449,7 @@ def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: def test_define_tool_secure_default(self) -> None: agent = self._make() + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} @@ -1385,6 +1460,7 @@ def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: def test_define_tool_not_secure(self) -> None: agent = self._make() + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} @@ -1395,6 +1471,7 @@ def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: def test_define_tool_duplicate_raises(self) -> None: agent = self._make() + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} @@ -1404,12 +1481,16 @@ def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: def test_define_tool_with_webhook_url(self) -> None: agent = self._make() + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} agent.define_tool( - "webhook_tool", "External tool", {}, handler, - webhook_url="https://external.com/api" + "webhook_tool", + "External tool", + {}, + handler, + webhook_url="https://external.com/api", ) func = agent._tool_registry._swaig_functions["webhook_tool"] assert isinstance(func, SWAIGFunction) @@ -1418,6 +1499,7 @@ def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: def test_define_tool_description(self) -> None: agent = self._make() + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} @@ -1428,6 +1510,7 @@ def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: def test_define_tool_complex_parameters(self) -> None: agent = self._make() + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} @@ -1435,10 +1518,14 @@ def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, - "limit": {"type": "integer", "description": "Max results", "default": 10}, + "limit": { + "type": "integer", + "description": "Max results", + "default": 10, + }, "tags": {"type": "array", "items": {"type": "string"}}, }, - "required": ["query"] + "required": ["query"], } agent.define_tool("complex_tool", "Complex params", params, handler) func = agent._tool_registry._swaig_functions["complex_tool"] @@ -1447,6 +1534,7 @@ def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: def test_define_tools_returns_list(self) -> None: agent = self._make() + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} @@ -1461,7 +1549,7 @@ def test_register_swaig_function_raw_dict(self) -> None: "function": "data_map_fn", "description": "A data map function", "parameters": {"type": "object", "properties": {}}, - "data_map": {"expressions": []} + "data_map": {"expressions": []}, } result = agent.register_swaig_function(func_dict) assert result is agent @@ -1485,26 +1573,32 @@ def test_on_function_call_missing_function(self) -> None: def test_on_function_call_data_map_function(self) -> None: agent = self._make() - agent.register_swaig_function({ - "function": "dm_fn", - "description": "DM", - "parameters": {}, - "data_map": {} - }) + agent.register_swaig_function( + {"function": "dm_fn", "description": "DM", "parameters": {}, "data_map": {}} + ) result = agent.on_function_call("dm_fn", {}) - assert "Data map" in result["response"] or "data_map" in result["response"].lower() + assert ( + "Data map" in result["response"] or "data_map" in result["response"].lower() + ) def test_on_function_call_success(self) -> None: agent = self._make() + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": f"got {args.get('x')}"} - agent.define_tool("fn", "test", {"type": "object", "properties": {"x": {"type": "string"}}}, handler) + agent.define_tool( + "fn", + "test", + {"type": "object", "properties": {"x": {"type": "string"}}}, + handler, + ) result = agent.on_function_call("fn", {"x": "hello"}) assert "got hello" in str(result) def test_on_function_call_handler_exception(self) -> None: agent = self._make() + def bad_handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: raise RuntimeError("boom") @@ -1514,16 +1608,20 @@ def bad_handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: def test_tool_appears_in_rendered_swml(self) -> None: agent = self._make() + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} agent.define_tool( - "rendered_tool", "A tool for render test", + "rendered_tool", + "A tool for render test", {"type": "object", "properties": {"q": {"type": "string"}}}, - handler + handler, ) doc = json.loads(agent._render_swml()) - ai_verb = [v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v][0] + ai_verb = next( + v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v + ) swaig = ai_verb["ai"].get("SWAIG", {}) assert "functions" in swaig tool_names = [f["function"] for f in swaig["functions"]] @@ -1542,7 +1640,9 @@ def test_add_skill_success(self) -> None: agent.skill_manager.load_skill.return_value = (True, "") result = agent.add_skill("test_skill", {"param": "value"}) assert result is agent - agent.skill_manager.load_skill.assert_called_once_with("test_skill", params={"param": "value"}) + agent.skill_manager.load_skill.assert_called_once_with( + "test_skill", params={"param": "value"} + ) def test_add_skill_failure_raises(self) -> None: agent = self._make() @@ -1556,7 +1656,9 @@ def test_add_skill_no_params(self) -> None: agent.skill_manager = Mock() agent.skill_manager.load_skill.return_value = (True, "") agent.add_skill("simple_skill") - agent.skill_manager.load_skill.assert_called_once_with("simple_skill", params=None) + agent.skill_manager.load_skill.assert_called_once_with( + "simple_skill", params=None + ) def test_remove_skill(self) -> None: agent = self._make() @@ -1588,6 +1690,7 @@ def test_skill_manager_initialized(self) -> None: """Verify skill_manager is a SkillManager by default.""" agent = self._make() from signalwire.core.skill_manager import SkillManager + assert isinstance(agent.skill_manager, SkillManager) def test_skill_manager_agent_reference(self) -> None: @@ -1645,8 +1748,10 @@ def _make(self, **kw: Any) -> AgentBase: def test_set_dynamic_config_callback(self) -> None: agent = self._make() + def callback(qp: Any, bp: Any, h: Any, a: Any) -> None: pass + result = agent.set_dynamic_config_callback(callback) assert result is agent assert agent._dynamic_config_callback is callback @@ -1679,7 +1784,8 @@ def test_find_summary_empty_body(self) -> None: def test_find_summary_direct_key(self) -> None: agent = self._make() - result = agent._find_summary_in_post_data({"summary": {"text": "hello"}}, agent.log) # type: ignore[typeddict-unknown-key] # exercises arbitrary post-data shape + find_summary = agent._find_summary_in_post_data + result = find_summary({"summary": {"text": "hello"}}, agent.log) # type: ignore[typeddict-unknown-key] # exercises arbitrary post-data shape assert result == {"text": "hello"} def test_find_summary_from_post_prompt_data_parsed(self) -> None: @@ -1767,8 +1873,9 @@ def _agent(self) -> AgentBase: ) @staticmethod - def _auth() -> Dict[str, str]: + def _auth() -> dict[str, str]: import base64 + creds = base64.b64encode(b"user:pass").decode() return {"Authorization": f"Basic {creds}"} @@ -1797,9 +1904,7 @@ def test_agent_core_401_on_missing_auth(self) -> None: def test_agent_core_307_routing_redirect(self) -> None: agent = self._agent() - agent.register_routing_callback( - lambda body, headers: "/elsewhere", "/sip" - ) + agent.register_routing_callback(lambda body, headers: "/elsewhere", "/sip") status, headers, body_str = agent.handle_request( "POST", "http://127.0.0.1:3000/sip", diff --git a/tests/unit/core/test_agent_server.py b/tests/unit/core/test_agent_server.py index 2ea9e7e3..dd70adba 100644 --- a/tests/unit/core/test_agent_server.py +++ b/tests/unit/core/test_agent_server.py @@ -31,7 +31,7 @@ import json from pathlib import Path from typing import Any -from unittest.mock import Mock, patch, MagicMock, call +from unittest.mock import Mock, patch, MagicMock from io import StringIO from fastapi.testclient import TestClient @@ -43,11 +43,7 @@ class SimpleTestAgent(AgentBase): """Simple agent for testing""" def __init__(self, name: str = "test_agent", route: str = "/test") -> None: - super().__init__( - name=name, - route=route, - use_pom=False - ) + super().__init__(name=name, route=route, use_pom=False) # Disable auth for testing self._auth_enabled = False @@ -76,6 +72,7 @@ def test_custom_initialization(self) -> None: def test_app_is_fastapi_instance(self) -> None: """Test that self.app is a FastAPI instance""" from fastapi import FastAPI + server = AgentServer() assert isinstance(server.app, FastAPI) @@ -282,7 +279,10 @@ def test_setup_sip_routing_auto_map_existing_agents(self) -> None: server.register(agent, "/support") server.setup_sip_routing(auto_map=True) # Should have mapped agent name and route - assert "support_bot" in server._sip_username_mapping or "supportbot" in server._sip_username_mapping + assert ( + "support_bot" in server._sip_username_mapping + or "supportbot" in server._sip_username_mapping + ) assert "support" in server._sip_username_mapping def test_setup_sip_routing_no_auto_map(self) -> None: @@ -347,7 +347,10 @@ def test_auto_map_agent_sip_usernames(self) -> None: # Now manually call auto-map server._auto_map_agent_sip_usernames(agent, "/sales") # "salesbot" (cleaned name) and "sales" (route part) should be mapped - assert "salesbot" in server._sip_username_mapping or "sales_bot" in server._sip_username_mapping + assert ( + "salesbot" in server._sip_username_mapping + or "sales_bot" in server._sip_username_mapping + ) assert "sales" in server._sip_username_mapping def test_register_agent_with_sip_routing_enabled(self) -> None: @@ -397,29 +400,44 @@ class TestRunMethod: def test_run_server_mode(self, mock_uvicorn: MagicMock) -> None: """Test run() in server mode delegates to _run_server""" server = AgentServer() - with patch("signalwire.core.logging_config.get_execution_mode", return_value="server"): + with patch( + "signalwire.core.logging_config.get_execution_mode", return_value="server" + ): server.run() mock_uvicorn.run.assert_called_once() def test_run_cgi_mode(self) -> None: """Test run() in CGI mode delegates to _handle_cgi_request""" server = AgentServer() - with patch("signalwire.core.logging_config.get_execution_mode", return_value="cgi"): - with patch.object(server, '_handle_cgi_request', return_value="cgi_response") as mock_cgi: - result = server.run() - mock_cgi.assert_called_once() - assert result == "cgi_response" + with ( + patch( + "signalwire.core.logging_config.get_execution_mode", return_value="cgi" + ), + patch.object( + server, "_handle_cgi_request", return_value="cgi_response" + ) as mock_cgi, + ): + result = server.run() + mock_cgi.assert_called_once() + assert result == "cgi_response" def test_run_lambda_mode(self) -> None: """Test run() in Lambda mode delegates to _handle_lambda_request""" server = AgentServer() event = {"path": "/test"} context = Mock() - with patch("signalwire.core.logging_config.get_execution_mode", return_value="lambda"): - with patch.object(server, '_handle_lambda_request', return_value={"statusCode": 200}) as mock_lambda: - result = server.run(event=event, context=context) - mock_lambda.assert_called_once_with(event, context) - assert result["statusCode"] == 200 + with ( + patch( + "signalwire.core.logging_config.get_execution_mode", + return_value="lambda", + ), + patch.object( + server, "_handle_lambda_request", return_value={"statusCode": 200} + ) as mock_lambda, + ): + result = server.run(event=event, context=context) + mock_lambda.assert_called_once_with(event, context) + assert result["statusCode"] == 200 class TestRunServer: @@ -431,10 +449,7 @@ def test_run_server_default_host_port(self, mock_uvicorn: MagicMock) -> None: server = AgentServer(host="0.0.0.0", port=3000) server._run_server() mock_uvicorn.run.assert_called_once_with( - server.app, - host="0.0.0.0", - port=3000, - log_level="info" + server.app, host="0.0.0.0", port=3000, log_level="info" ) @patch("signalwire.agent_server.uvicorn") @@ -443,17 +458,16 @@ def test_run_server_override_host_port(self, mock_uvicorn: MagicMock) -> None: server = AgentServer() server._run_server(host="127.0.0.1", port=9999) mock_uvicorn.run.assert_called_once_with( - server.app, - host="127.0.0.1", - port=9999, - log_level="info" + server.app, host="127.0.0.1", port=9999, log_level="info" ) @patch("signalwire.agent_server.uvicorn") def test_run_server_with_ssl(self, mock_uvicorn: MagicMock) -> None: """Test _run_server with SSL enabled via environment variables""" - with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as cert_f, \ - tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as key_f: + with ( + tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as cert_f, + tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as key_f, + ): cert_path = cert_f.name key_path = key_f.name @@ -476,26 +490,48 @@ def test_run_server_with_ssl(self, mock_uvicorn: MagicMock) -> None: ssl_keyfile=key_path, ) finally: - os.unlink(cert_path) - os.unlink(key_path) + Path(cert_path).unlink() + Path(key_path).unlink() @patch("signalwire.agent_server.uvicorn") - def test_run_server_ssl_disabled_bad_cert(self, mock_uvicorn: MagicMock) -> None: - """Test _run_server falls back to non-SSL if cert not found""" + def test_run_server_missing_cert_refuses_to_start( + self, mock_uvicorn: MagicMock + ) -> None: + """TLS requested but no cert must FAIL, never serve plaintext. + + Silently clearing ssl_enabled would hand the operator a cleartext + listener carrying their Basic-auth credentials, with no error. + """ env = { "SWML_SSL_ENABLED": "true", "SWML_SSL_CERT_PATH": "/nonexistent/cert.pem", "SWML_SSL_KEY_PATH": "/nonexistent/key.pem", } + with patch.dict(os.environ, env, clear=False): + server = AgentServer() + with pytest.raises(RuntimeError, match="SSL certificate is missing"): + server._run_server() + mock_uvicorn.run.assert_not_called() + + @patch("signalwire.agent_server.uvicorn") + def test_run_server_ssl_disabled_still_serves_plain_http( + self, mock_uvicorn: MagicMock + ) -> None: + """Scope control: with SSL off, plain HTTP must still serve. + + The refusal above must reject only a TLS request it cannot satisfy — + not every start. + """ + env = { + "SWML_SSL_ENABLED": "false", + "SWML_SSL_CERT_PATH": "/nonexistent/cert.pem", + "SWML_SSL_KEY_PATH": "/nonexistent/key.pem", + } with patch.dict(os.environ, env, clear=False): server = AgentServer() server._run_server() - # Should call without ssl params mock_uvicorn.run.assert_called_once_with( - server.app, - host="0.0.0.0", - port=3000, - log_level="info" + server.app, host="0.0.0.0", port=3000, log_level="info" ) @patch("signalwire.agent_server.uvicorn") @@ -507,8 +543,10 @@ def test_run_server_no_agents_warning(self, mock_uvicorn: MagicMock) -> None: mock_uvicorn.run.assert_called_once() @patch("signalwire.agent_server.uvicorn") - def test_run_server_ssl_missing_key(self, mock_uvicorn: MagicMock) -> None: - """Test _run_server falls back when SSL key path is missing""" + def test_run_server_missing_key_refuses_to_start( + self, mock_uvicorn: MagicMock + ) -> None: + """TLS requested with a cert but no key must FAIL, never serve plaintext.""" with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as cert_f: cert_path = cert_f.name @@ -520,16 +558,11 @@ def test_run_server_ssl_missing_key(self, mock_uvicorn: MagicMock) -> None: } with patch.dict(os.environ, env, clear=False): server = AgentServer() - server._run_server() - # Should fall back to non-SSL - mock_uvicorn.run.assert_called_once_with( - server.app, - host="0.0.0.0", - port=3000, - log_level="info" - ) + with pytest.raises(RuntimeError, match="SSL private key is missing"): + server._run_server() + mock_uvicorn.run.assert_not_called() finally: - os.unlink(cert_path) + Path(cert_path).unlink() class TestHandleLambdaRequest: @@ -639,10 +672,12 @@ class TestHandleCgiRequest: def test_cgi_no_path_returns_404(self) -> None: """Test CGI request with no PATH_INFO returns 404""" server = AgentServer() - with patch.dict(os.environ, {"PATH_INFO": ""}, clear=False): - with patch("sys.stdout", new_callable=StringIO): - result = server._handle_cgi_request() - assert "404 Not Found" in result + with ( + patch.dict(os.environ, {"PATH_INFO": ""}, clear=False), + patch("sys.stdout", new_callable=StringIO), + ): + result = server._handle_cgi_request() + assert "404 Not Found" in result def test_cgi_matching_agent_returns_swml(self) -> None: """Test CGI request that matches an agent returns SWML""" @@ -650,10 +685,12 @@ def test_cgi_matching_agent_returns_swml(self) -> None: agent = SimpleTestAgent(name="myagent") agent._render_swml = Mock(return_value={"version": "1.0.0"}) # type: ignore[method-assign] # mock server.register(agent, "/myagent") - with patch.dict(os.environ, {"PATH_INFO": "/myagent"}, clear=False): - with patch("sys.stdout", new_callable=StringIO): - result = server._handle_cgi_request() - assert "200 OK" in result + with ( + patch.dict(os.environ, {"PATH_INFO": "/myagent"}, clear=False), + patch("sys.stdout", new_callable=StringIO), + ): + result = server._handle_cgi_request() + assert "200 OK" in result def test_cgi_matching_agent_render_error(self) -> None: """Test CGI request when agent render fails returns 500""" @@ -661,19 +698,23 @@ def test_cgi_matching_agent_render_error(self) -> None: agent = SimpleTestAgent(name="broken") agent._render_swml = Mock(side_effect=Exception("render failed")) # type: ignore[method-assign] # mock server.register(agent, "/broken") - with patch.dict(os.environ, {"PATH_INFO": "/broken"}, clear=False): - with patch("sys.stdout", new_callable=StringIO): - result = server._handle_cgi_request() - assert "500 Internal Server Error" in result + with ( + patch.dict(os.environ, {"PATH_INFO": "/broken"}, clear=False), + patch("sys.stdout", new_callable=StringIO), + ): + result = server._handle_cgi_request() + assert "500 Internal Server Error" in result def test_cgi_no_matching_agent_returns_404(self) -> None: """Test CGI request with no matching agent returns 404""" server = AgentServer() server.register(SimpleTestAgent(), "/test") - with patch.dict(os.environ, {"PATH_INFO": "/nonexistent"}, clear=False): - with patch("sys.stdout", new_callable=StringIO): - result = server._handle_cgi_request() - assert "404 Not Found" in result + with ( + patch.dict(os.environ, {"PATH_INFO": "/nonexistent"}, clear=False), + patch("sys.stdout", new_callable=StringIO), + ): + result = server._handle_cgi_request() + assert "404 Not Found" in result def test_cgi_swaig_subpath(self) -> None: """Test CGI request to swaig subpath with no body""" @@ -709,10 +750,12 @@ def test_cgi_swaig_exception(self) -> None: agent._execute_swaig_function = Mock(side_effect=Exception("swaig error")) # type: ignore[method-assign] # mock server.register(agent, "/myagent") env = {"PATH_INFO": "/myagent/swaig", "CONTENT_LENGTH": "0"} - with patch.dict(os.environ, env, clear=False): - with patch("sys.stdout", new_callable=StringIO): - result = server._handle_cgi_request() - assert "500 Internal Server Error" in result + with ( + patch.dict(os.environ, env, clear=False), + patch("sys.stdout", new_callable=StringIO), + ): + result = server._handle_cgi_request() + assert "500 Internal Server Error" in result def test_cgi_swaig_function_exception(self) -> None: """Test CGI request to swaig/ that raises exception""" @@ -721,10 +764,12 @@ def test_cgi_swaig_function_exception(self) -> None: agent._execute_swaig_function = Mock(side_effect=Exception("func error")) # type: ignore[method-assign] # mock server.register(agent, "/myagent") env = {"PATH_INFO": "/myagent/swaig/broken_func", "CONTENT_LENGTH": "0"} - with patch.dict(os.environ, env, clear=False): - with patch("sys.stdout", new_callable=StringIO): - result = server._handle_cgi_request() - assert "500 Internal Server Error" in result + with ( + patch.dict(os.environ, env, clear=False), + patch("sys.stdout", new_callable=StringIO), + ): + result = server._handle_cgi_request() + assert "500 Internal Server Error" in result class TestFormatCgiResponse: @@ -750,7 +795,9 @@ def test_format_cgi_response_custom_status(self) -> None: """Test formatting with custom status""" server = AgentServer() with patch("sys.stdout", new_callable=StringIO): - result = server._format_cgi_response({"error": "nope"}, status="404 Not Found") + result = server._format_cgi_response( + {"error": "nope"}, status="404 Not Found" + ) assert "Status: 404 Not Found" in result def test_format_cgi_response_custom_content_type(self) -> None: @@ -773,8 +820,10 @@ def test_register_global_routing_callback(self) -> None: server.register(agent2, "/a2") callback = Mock() - with patch.object(agent1, 'register_routing_callback') as mock1, \ - patch.object(agent2, 'register_routing_callback') as mock2: + with ( + patch.object(agent1, "register_routing_callback") as mock1, + patch.object(agent2, "register_routing_callback") as mock2, + ): server.register_global_routing_callback(callback, path="/sip") mock1.assert_called_once_with(callback, path="/sip") mock2.assert_called_once_with(callback, path="/sip") @@ -786,7 +835,7 @@ def test_register_global_routing_callback_normalizes_path(self) -> None: server.register(agent, "/a1") callback = Mock() - with patch.object(agent, 'register_routing_callback') as mock_reg: + with patch.object(agent, "register_routing_callback") as mock_reg: server.register_global_routing_callback(callback, path="sip/") mock_reg.assert_called_once_with(callback, path="/sip") @@ -799,7 +848,7 @@ def test_serve_static_files_valid_directory(self) -> None: server = AgentServer() with tempfile.TemporaryDirectory() as tmpdir: server.serve_static_files(tmpdir) - assert hasattr(server, '_static_directories') + assert hasattr(server, "_static_directories") assert "" in server._static_directories or "/" in server._static_directories def test_serve_static_files_nonexistent_directory(self) -> None: @@ -811,9 +860,11 @@ def test_serve_static_files_nonexistent_directory(self) -> None: def test_serve_static_files_file_not_directory(self) -> None: """Test serve_static_files with a file path instead of directory""" server = AgentServer() - with tempfile.NamedTemporaryFile() as tmpfile: - with pytest.raises(ValueError, match="not a directory"): - server.serve_static_files(tmpfile.name) + with ( + tempfile.NamedTemporaryFile() as tmpfile, + pytest.raises(ValueError, match="not a directory"), + ): + server.serve_static_files(tmpfile.name) def test_serve_static_files_custom_route(self) -> None: """Test serve_static_files with custom route prefix""" @@ -832,6 +883,7 @@ def test_serve_static_file_existing_file(self) -> None: """_serve_static_file returns a FileResponse pointed at the requested file, not at some other path.""" from fastapi.responses import FileResponse + server = AgentServer() with tempfile.TemporaryDirectory() as tmpdir: resolved_dir = Path(tmpdir).resolve() @@ -860,6 +912,7 @@ def test_serve_static_file_empty_path_serves_index(self) -> None: returned FileResponse must point at index.html, not at the directory or some sibling file.""" from fastapi.responses import FileResponse + server = AgentServer() with tempfile.TemporaryDirectory() as tmpdir: resolved_dir = Path(tmpdir).resolve() @@ -879,6 +932,7 @@ def test_serve_static_file_directory_with_index(self) -> None: specifically — not at the directory itself, and not at any sibling file the directory happens to contain.""" from fastapi.responses import FileResponse + server = AgentServer() with tempfile.TemporaryDirectory() as tmpdir: resolved_dir = Path(tmpdir).resolve() @@ -894,10 +948,10 @@ def test_serve_static_file_directory_with_index(self) -> None: assert isinstance(result, FileResponse) assert str(result.path) == str(index_file) - def test_serve_static_file_route_not_found(self) -> None: + def test_serve_static_file_route_not_found(self, tmp_path: Path) -> None: """Test _serve_static_file returns None for unknown route""" server = AgentServer() - server._static_directories = {"/assets": Path("/tmp")} + server._static_directories = {"/assets": tmp_path} result = server._serve_static_file("test.txt", route="/other") assert result is None @@ -917,26 +971,28 @@ def test_custom_route_not_overshadowed_by_catch_all(self) -> None: server.register(SimpleTestAgent(), "/agent") # Add a custom route AFTER server creation (like santa's /get_token) - @server.app.get('/get_token') + @server.app.get("/get_token") def get_token() -> dict[str, Any]: return {"token": "test-token-123", "success": True} # Add another custom route - @server.app.get('/health_custom') + @server.app.get("/health_custom") def health_custom() -> dict[str, Any]: return {"status": "healthy"} client = TestClient(server.app) # Test custom route works - response = client.get('/get_token') - assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}" + response = client.get("/get_token") + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}: {response.text}" + ) data = response.json() assert data["token"] == "test-token-123" assert data["success"] is True # Test another custom route - response = client.get('/health_custom') + response = client.get("/health_custom") assert response.status_code == 200 assert response.json()["status"] == "healthy" @@ -948,45 +1004,44 @@ def test_health_endpoints_work(self) -> None: client = TestClient(server.app) # Health endpoint should work - response = client.get('/health') + response = client.get("/health") assert response.status_code == 200 assert response.json()["status"] == "ok" # Ready endpoint should work - response = client.get('/ready') + response = client.get("/ready") assert response.status_code == 200 assert response.json()["status"] == "ready" - def test_multiple_custom_routes(self) -> None: """Test multiple custom routes all work correctly""" server = AgentServer() server.register(SimpleTestAgent(), "/agent") # Add multiple custom routes - @server.app.get('/route1') + @server.app.get("/route1") def route1() -> dict[str, Any]: return {"route": 1} - @server.app.get('/route2') + @server.app.get("/route2") def route2() -> dict[str, Any]: return {"route": 2} - @server.app.post('/route3') + @server.app.post("/route3") def route3() -> dict[str, Any]: return {"route": 3} - @server.app.get('/nested/deep/route') + @server.app.get("/nested/deep/route") def nested() -> dict[str, Any]: return {"route": "nested"} client = TestClient(server.app) # All routes should work - assert client.get('/route1').json()["route"] == 1 - assert client.get('/route2').json()["route"] == 2 - assert client.post('/route3').json()["route"] == 3 - assert client.get('/nested/deep/route').json()["route"] == "nested" + assert client.get("/route1").json()["route"] == 1 + assert client.get("/route2").json()["route"] == 2 + assert client.post("/route3").json()["route"] == 3 + assert client.get("/nested/deep/route").json()["route"] == "nested" def test_nonexistent_route_returns_404(self) -> None: """Test that truly nonexistent routes return 404""" @@ -996,7 +1051,7 @@ def test_nonexistent_route_returns_404(self) -> None: client = TestClient(server.app) # Nonexistent route should 404 - response = client.get('/nonexistent/path') + response = client.get("/nonexistent/path") assert response.status_code == 404 def test_post_custom_routes_work(self) -> None: @@ -1004,13 +1059,13 @@ def test_post_custom_routes_work(self) -> None: server = AgentServer() server.register(SimpleTestAgent(), "/agent") - @server.app.post('/webhook') + @server.app.post("/webhook") def webhook() -> dict[str, Any]: return {"received": True} client = TestClient(server.app) - response = client.post('/webhook', json={"data": "test"}) + response = client.post("/webhook", json={"data": "test"}) assert response.status_code == 200 assert response.json()["received"] is True @@ -1032,19 +1087,19 @@ def test_app_property_works_for_gunicorn(self) -> None: app = server.app # Add routes to the app (like santa does) - @app.get('/get_token') + @app.get("/get_token") def get_token() -> dict[str, Any]: return {"token": "gunicorn-test"} client = TestClient(app) # Custom route should work - response = client.get('/get_token') + response = client.get("/get_token") assert response.status_code == 200 assert response.json()["token"] == "gunicorn-test" # Health should work - response = client.get('/health') + response = client.get("/health") assert response.status_code == 200 def test_custom_routes_work_with_gunicorn_pattern(self) -> None: @@ -1053,15 +1108,15 @@ def test_custom_routes_work_with_gunicorn_pattern(self) -> None: server.register(SimpleTestAgent(), "/agent") # Add multiple custom endpoints like a real app would - @server.app.get('/get_credentials') + @server.app.get("/get_credentials") def get_credentials() -> dict[str, Any]: return {"user": "test", "pass": "secret"} - @server.app.get('/get_resource_info') + @server.app.get("/get_resource_info") def get_resource_info() -> dict[str, Any]: return {"resource_id": "123"} - @server.app.post('/webhook') + @server.app.post("/webhook") def webhook() -> dict[str, Any]: return {"status": "received"} @@ -1069,9 +1124,9 @@ def webhook() -> dict[str, Any]: client = TestClient(server.app) # All custom routes should work - assert client.get('/get_credentials').status_code == 200 - assert client.get('/get_resource_info').status_code == 200 - assert client.post('/webhook').status_code == 200 + assert client.get("/get_credentials").status_code == 200 + assert client.get("/get_resource_info").status_code == 200 + assert client.post("/webhook").status_code == 200 # Health should still work - assert client.get('/health').status_code == 200 + assert client.get("/health").status_code == 200 diff --git a/tests/unit/core/test_auth_handler.py b/tests/unit/core/test_auth_handler.py index 3baed1d2..49bb057b 100644 --- a/tests/unit/core/test_auth_handler.py +++ b/tests/unit/core/test_auth_handler.py @@ -16,21 +16,36 @@ from collections.abc import Coroutine from typing import Any, TYPE_CHECKING, TypeVar import pytest -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import Mock, patch if TYPE_CHECKING: from signalwire.core.auth_handler import AuthHandler _T = TypeVar("_T") +# Fixture credentials for tests that exercise auth *plumbing* (is the dependency +# callable, does a missing credential raise 401, does the decorator preserve +# __name__) rather than credential matching. Tests that DO care about the values +# pass their own explicitly. Named constants keep the literals out of function +# signatures, where they would read as a hardcoded credential default. +_FIXTURE_USER = "testuser" +_FIXTURE_PASSWORD = "testpass" + +# Same rationale for the credential-carrier tests: keep the literals out of +# function signatures, where ruff's S107 reads them as a hardcoded default. +_CARRIER_USER = "admin" +_CARRIER_PASSWORD = "secret" +_CARRIER_TOKEN = "tok" + # --------------------------------------------------------------------------- # Helpers: build a mock SecurityConfig that behaves like the real one # --------------------------------------------------------------------------- + def _make_security_config( - username: str = "testuser", - password: str = "testpass", + username: str = _FIXTURE_USER, + password: str = _FIXTURE_PASSWORD, bearer_token: str | None = None, api_key: str | None = None, api_key_header: str = "X-API-Key", @@ -55,6 +70,7 @@ def _run_async(coro: Coroutine[Any, Any, _T]) -> _T: # AuthHandler initialisation and _setup_auth_methods # =========================================================================== + class TestAuthHandlerInit: """Test AuthHandler construction and _setup_auth_methods.""" @@ -66,11 +82,11 @@ def test_basic_init_with_basic_auth_only(self) -> None: handler = AuthHandler(cfg) assert handler.security_config is cfg - assert handler.auth_methods['basic']['enabled'] is True - assert handler.auth_methods['basic']['username'] == "admin" - assert handler.auth_methods['basic']['password'] == "secret" - assert 'bearer' not in handler.auth_methods - assert 'api_key' not in handler.auth_methods + assert handler.auth_methods["basic"]["enabled"] is True + assert handler.auth_methods["basic"]["username"] == "admin" + assert handler.auth_methods["basic"]["password"] == "secret" + assert "bearer" not in handler.auth_methods + assert "api_key" not in handler.auth_methods def test_init_with_bearer_token(self) -> None: """AuthHandler registers bearer method when token is configured.""" @@ -79,9 +95,9 @@ def test_init_with_bearer_token(self) -> None: cfg = _make_security_config(bearer_token="my-token-123") handler = AuthHandler(cfg) - assert 'bearer' in handler.auth_methods - assert handler.auth_methods['bearer']['enabled'] is True - assert handler.auth_methods['bearer']['token'] == "my-token-123" + assert "bearer" in handler.auth_methods + assert handler.auth_methods["bearer"]["enabled"] is True + assert handler.auth_methods["bearer"]["token"] == "my-token-123" def test_init_with_api_key(self) -> None: """AuthHandler registers api_key method when key is configured.""" @@ -90,9 +106,9 @@ def test_init_with_api_key(self) -> None: cfg = _make_security_config(api_key="ak_abc123") handler = AuthHandler(cfg) - assert 'api_key' in handler.auth_methods - assert handler.auth_methods['api_key']['enabled'] is True - assert handler.auth_methods['api_key']['key'] == "ak_abc123" + assert "api_key" in handler.auth_methods + assert handler.auth_methods["api_key"]["enabled"] is True + assert handler.auth_methods["api_key"]["key"] == "ak_abc123" def test_init_with_custom_api_key_header(self) -> None: """AuthHandler respects a custom api_key_header from SecurityConfig.""" @@ -101,7 +117,7 @@ def test_init_with_custom_api_key_header(self) -> None: cfg = _make_security_config(api_key="ak_abc", api_key_header="X-Custom-Key") handler = AuthHandler(cfg) - assert handler.auth_methods['api_key']['header'] == "X-Custom-Key" + assert handler.auth_methods["api_key"]["header"] == "X-Custom-Key" def test_init_with_default_api_key_header(self) -> None: """When api_key_header attribute is missing, default to X-API-Key.""" @@ -115,21 +131,20 @@ def test_init_with_default_api_key_header(self) -> None: del cfg.api_key_header handler = AuthHandler(cfg) - assert handler.auth_methods['api_key']['header'] == "X-API-Key" + assert handler.auth_methods["api_key"]["header"] == "X-API-Key" def test_init_with_all_methods(self) -> None: """AuthHandler initializes all three auth methods when all configured.""" from signalwire.core.auth_handler import AuthHandler cfg = _make_security_config( - username="u", password="p", - bearer_token="tok", api_key="key" + username="u", password="p", bearer_token="tok", api_key="key" ) handler = AuthHandler(cfg) - assert 'basic' in handler.auth_methods - assert 'bearer' in handler.auth_methods - assert 'api_key' in handler.auth_methods + assert "basic" in handler.auth_methods + assert "bearer" in handler.auth_methods + assert "api_key" in handler.auth_methods def test_bearer_not_registered_when_none(self) -> None: """Bearer method is not added when bearer_token is None.""" @@ -137,7 +152,7 @@ def test_bearer_not_registered_when_none(self) -> None: cfg = _make_security_config(bearer_token=None) handler = AuthHandler(cfg) - assert 'bearer' not in handler.auth_methods + assert "bearer" not in handler.auth_methods def test_bearer_not_registered_when_empty_string(self) -> None: """Bearer method is not added when bearer_token is empty string (falsy).""" @@ -145,7 +160,7 @@ def test_bearer_not_registered_when_empty_string(self) -> None: cfg = _make_security_config(bearer_token="") handler = AuthHandler(cfg) - assert 'bearer' not in handler.auth_methods + assert "bearer" not in handler.auth_methods def test_api_key_not_registered_when_none(self) -> None: """api_key method is not added when api_key is None.""" @@ -153,7 +168,7 @@ def test_api_key_not_registered_when_none(self) -> None: cfg = _make_security_config(api_key=None) handler = AuthHandler(cfg) - assert 'api_key' not in handler.auth_methods + assert "api_key" not in handler.auth_methods def test_auto_error_false_on_http_basic(self) -> None: """HTTPBasic is created with auto_error=False so credentials are optional.""" @@ -162,7 +177,7 @@ def test_auto_error_false_on_http_basic(self) -> None: with patch("signalwire.core.auth_handler.HTTPBasic") as mock_cls: mock_cls.return_value = Mock() cfg = _make_security_config() - handler = AuthHandler(cfg) + AuthHandler(cfg) mock_cls.assert_called_once_with(auto_error=False) def test_auto_error_false_on_http_bearer(self) -> None: @@ -172,7 +187,7 @@ def test_auto_error_false_on_http_bearer(self) -> None: with patch("signalwire.core.auth_handler.HTTPBearer") as mock_cls: mock_cls.return_value = Mock() cfg = _make_security_config() - handler = AuthHandler(cfg) + AuthHandler(cfg) mock_cls.assert_called_once_with(auto_error=False) def test_basic_auth_always_enabled(self) -> None: @@ -181,18 +196,22 @@ def test_basic_auth_always_enabled(self) -> None: cfg = _make_security_config(username="", password="") handler = AuthHandler(cfg) - assert handler.auth_methods['basic']['enabled'] is True + assert handler.auth_methods["basic"]["enabled"] is True # =========================================================================== # verify_basic_auth -- timing-safe comparison # =========================================================================== + class TestVerifyBasicAuth: """Test the verify_basic_auth method, including timing-safe comparison.""" - def _make_handler(self, username: str = "user", password: str = "pass") -> "AuthHandler": + def _make_handler( + self, username: str = _FIXTURE_USER, password: str = _FIXTURE_PASSWORD + ) -> "AuthHandler": from signalwire.core.auth_handler import AuthHandler + cfg = _make_security_config(username=username, password=password) return AuthHandler(cfg) @@ -246,7 +265,10 @@ def test_uses_secrets_compare_digest(self) -> None: handler = AuthHandler(cfg) creds = self._make_creds("admin", "secret") - with patch("signalwire.core.auth_handler.secrets.compare_digest", wraps=secrets.compare_digest) as mock_cd: + with patch( + "signalwire.core.auth_handler.secrets.compare_digest", + wraps=secrets.compare_digest, + ) as mock_cd: result = handler.verify_basic_auth(creds) assert result is True assert mock_cd.call_count == 2 @@ -266,7 +288,10 @@ def test_timing_safe_even_on_username_mismatch(self) -> None: handler = AuthHandler(cfg) creds = self._make_creds("wrong", "secret") - with patch("signalwire.core.auth_handler.secrets.compare_digest", wraps=secrets.compare_digest) as mock_cd: + with patch( + "signalwire.core.auth_handler.secrets.compare_digest", + wraps=secrets.compare_digest, + ) as mock_cd: result = handler.verify_basic_auth(creds) assert result is False # Both comparisons should still occur (no short-circuit) @@ -280,7 +305,10 @@ def test_timing_safe_even_on_password_mismatch(self) -> None: handler = AuthHandler(cfg) creds = self._make_creds("admin", "wrong") - with patch("signalwire.core.auth_handler.secrets.compare_digest", wraps=secrets.compare_digest) as mock_cd: + with patch( + "signalwire.core.auth_handler.secrets.compare_digest", + wraps=secrets.compare_digest, + ) as mock_cd: result = handler.verify_basic_auth(creds) assert result is False assert mock_cd.call_count == 2 @@ -292,7 +320,7 @@ def test_basic_auth_disabled(self) -> None: cfg = _make_security_config() handler = AuthHandler(cfg) # Manually disable basic auth - handler.auth_methods['basic']['enabled'] = False + handler.auth_methods["basic"]["enabled"] = False creds = self._make_creds("testuser", "testpass") assert handler.verify_basic_auth(creds) is False @@ -303,7 +331,7 @@ def test_basic_auth_missing_from_methods(self) -> None: cfg = _make_security_config() handler = AuthHandler(cfg) - del handler.auth_methods['basic'] + del handler.auth_methods["basic"] creds = self._make_creds("testuser", "testpass") assert handler.verify_basic_auth(creds) is False @@ -325,11 +353,13 @@ def test_case_sensitive_password(self) -> None: # verify_bearer_token -- timing-safe comparison # =========================================================================== + class TestVerifyBearerToken: """Test the verify_bearer_token method.""" - def _make_handler(self, bearer_token: str = "tok_abc") -> "AuthHandler": + def _make_handler(self, bearer_token: str) -> "AuthHandler": from signalwire.core.auth_handler import AuthHandler + cfg = _make_security_config(bearer_token=bearer_token) return AuthHandler(cfg) @@ -359,6 +389,7 @@ def test_empty_token(self) -> None: def test_bearer_not_configured(self) -> None: """When no bearer token is configured, returns False.""" from signalwire.core.auth_handler import AuthHandler + cfg = _make_security_config(bearer_token=None) handler = AuthHandler(cfg) @@ -368,7 +399,7 @@ def test_bearer_not_configured(self) -> None: def test_bearer_disabled(self) -> None: """When bearer is configured but disabled, returns False.""" handler = self._make_handler("tok") - handler.auth_methods['bearer']['enabled'] = False + handler.auth_methods["bearer"]["enabled"] = False creds = self._make_bearer_creds("tok") assert handler.verify_bearer_token(creds) is False @@ -378,7 +409,10 @@ def test_uses_secrets_compare_digest(self) -> None: handler = self._make_handler("tok_xyz") creds = self._make_bearer_creds("tok_xyz") - with patch("signalwire.core.auth_handler.secrets.compare_digest", wraps=secrets.compare_digest) as mock_cd: + with patch( + "signalwire.core.auth_handler.secrets.compare_digest", + wraps=secrets.compare_digest, + ) as mock_cd: result = handler.verify_bearer_token(creds) assert result is True mock_cd.assert_called_once_with("tok_xyz", "tok_xyz") @@ -386,9 +420,10 @@ def test_uses_secrets_compare_digest(self) -> None: def test_bearer_missing_from_methods(self) -> None: """When 'bearer' key is absent from auth_methods, returns False.""" from signalwire.core.auth_handler import AuthHandler + cfg = _make_security_config(bearer_token="tok") handler = AuthHandler(cfg) - del handler.auth_methods['bearer'] + del handler.auth_methods["bearer"] creds = self._make_bearer_creds("tok") assert handler.verify_bearer_token(creds) is False @@ -404,11 +439,13 @@ def test_token_case_sensitive(self) -> None: # verify_api_key -- timing-safe comparison # =========================================================================== + class TestVerifyApiKey: """Test the verify_api_key method.""" def _make_handler(self, api_key: str = "ak_secret") -> "AuthHandler": from signalwire.core.auth_handler import AuthHandler + cfg = _make_security_config(api_key=api_key) return AuthHandler(cfg) @@ -430,6 +467,7 @@ def test_empty_api_key(self) -> None: def test_api_key_not_configured(self) -> None: """When no API key configured, returns False.""" from signalwire.core.auth_handler import AuthHandler + cfg = _make_security_config(api_key=None) handler = AuthHandler(cfg) assert handler.verify_api_key("anything") is False @@ -437,22 +475,26 @@ def test_api_key_not_configured(self) -> None: def test_api_key_disabled(self) -> None: """When api_key is configured but disabled, returns False.""" handler = self._make_handler("ak_123") - handler.auth_methods['api_key']['enabled'] = False + handler.auth_methods["api_key"]["enabled"] = False assert handler.verify_api_key("ak_123") is False def test_uses_secrets_compare_digest(self) -> None: """Ensure timing-safe comparison via secrets.compare_digest.""" handler = self._make_handler("ak_xyz") - with patch("signalwire.core.auth_handler.secrets.compare_digest", wraps=secrets.compare_digest) as mock_cd: + with patch( + "signalwire.core.auth_handler.secrets.compare_digest", + wraps=secrets.compare_digest, + ) as mock_cd: handler.verify_api_key("ak_xyz") mock_cd.assert_called_once_with("ak_xyz", "ak_xyz") def test_api_key_missing_from_methods(self) -> None: """When 'api_key' key is absent from auth_methods, returns False.""" from signalwire.core.auth_handler import AuthHandler + cfg = _make_security_config(api_key="ak") handler = AuthHandler(cfg) - del handler.auth_methods['api_key'] + del handler.auth_methods["api_key"] assert handler.verify_api_key("ak") is False def test_api_key_case_sensitive(self) -> None: @@ -465,11 +507,13 @@ def test_api_key_case_sensitive(self) -> None: # get_fastapi_dependency -- auth enforcement # =========================================================================== + class TestGetFastapiDependency: """Test the FastAPI dependency factory.""" def _make_handler(self, **kwargs: Any) -> "AuthHandler": from signalwire.core.auth_handler import AuthHandler + cfg = _make_security_config(**kwargs) return AuthHandler(cfg) @@ -495,9 +539,11 @@ def test_basic_auth_succeeds(self) -> None: basic_creds.username = "u" basic_creds.password = "p" - result = _run_async(dep(basic_credentials=basic_creds, bearer_credentials=None, api_key=None)) - assert result['authenticated'] is True - assert result['method'] == 'basic' + result = _run_async( + dep(basic_credentials=basic_creds, bearer_credentials=None, api_key=None) + ) + assert result["authenticated"] is True + assert result["method"] == "basic" def test_basic_auth_fails_raises_401(self) -> None: """Auth dependency raises HTTPException(401) on bad basic creds.""" @@ -512,7 +558,9 @@ def test_basic_auth_fails_raises_401(self) -> None: bad_creds.password = "bad" with pytest.raises(HTTPException) as exc_info: - _run_async(dep(basic_credentials=bad_creds, bearer_credentials=None, api_key=None)) + _run_async( + dep(basic_credentials=bad_creds, bearer_credentials=None, api_key=None) + ) assert exc_info.value.status_code == 401 def test_no_credentials_raises_401(self) -> None: @@ -524,7 +572,9 @@ def test_no_credentials_raises_401(self) -> None: assert dep is not None with pytest.raises(HTTPException) as exc_info: - _run_async(dep(basic_credentials=None, bearer_credentials=None, api_key=None)) + _run_async( + dep(basic_credentials=None, bearer_credentials=None, api_key=None) + ) assert exc_info.value.status_code == 401 def test_no_credentials_optional_returns_unauthenticated(self) -> None: @@ -533,9 +583,11 @@ def test_no_credentials_optional_returns_unauthenticated(self) -> None: dep = handler.get_fastapi_dependency(optional=True) assert dep is not None - result = _run_async(dep(basic_credentials=None, bearer_credentials=None, api_key=None)) - assert result['authenticated'] is False - assert result['method'] is None + result = _run_async( + dep(basic_credentials=None, bearer_credentials=None, api_key=None) + ) + assert result["authenticated"] is False + assert result["method"] is None def test_bearer_auth_succeeds(self) -> None: """Auth dependency accepts valid bearer token.""" @@ -546,9 +598,11 @@ def test_bearer_auth_succeeds(self) -> None: bearer_creds = Mock() bearer_creds.credentials = "my_tok" - result = _run_async(dep(basic_credentials=None, bearer_credentials=bearer_creds, api_key=None)) - assert result['authenticated'] is True - assert result['method'] == 'bearer' + result = _run_async( + dep(basic_credentials=None, bearer_credentials=bearer_creds, api_key=None) + ) + assert result["authenticated"] is True + assert result["method"] == "bearer" def test_bearer_takes_precedence_over_basic(self) -> None: """When both bearer and basic credentials are provided, bearer wins.""" @@ -562,8 +616,14 @@ def test_bearer_takes_precedence_over_basic(self) -> None: basic_creds.username = "u" basic_creds.password = "p" - result = _run_async(dep(basic_credentials=basic_creds, bearer_credentials=bearer_creds, api_key=None)) - assert result['method'] == 'bearer' + result = _run_async( + dep( + basic_credentials=basic_creds, + bearer_credentials=bearer_creds, + api_key=None, + ) + ) + assert result["method"] == "bearer" def test_bad_bearer_falls_back_to_basic(self) -> None: """When bearer fails but basic succeeds, result method is 'basic'.""" @@ -577,9 +637,15 @@ def test_bad_bearer_falls_back_to_basic(self) -> None: good_basic.username = "u" good_basic.password = "p" - result = _run_async(dep(basic_credentials=good_basic, bearer_credentials=bad_bearer, api_key=None)) - assert result['authenticated'] is True - assert result['method'] == 'basic' + result = _run_async( + dep( + basic_credentials=good_basic, + bearer_credentials=bad_bearer, + api_key=None, + ) + ) + assert result["authenticated"] is True + assert result["method"] == "basic" def test_401_includes_www_authenticate_header(self) -> None: """HTTPException includes WWW-Authenticate: Basic header.""" @@ -590,7 +656,9 @@ def test_401_includes_www_authenticate_header(self) -> None: assert dep is not None with pytest.raises(HTTPException) as exc_info: - _run_async(dep(basic_credentials=None, bearer_credentials=None, api_key=None)) + _run_async( + dep(basic_credentials=None, bearer_credentials=None, api_key=None) + ) assert exc_info.value.headers == {"WWW-Authenticate": "Basic"} def test_401_detail_message(self) -> None: @@ -602,7 +670,9 @@ def test_401_detail_message(self) -> None: assert dep is not None with pytest.raises(HTTPException) as exc_info: - _run_async(dep(basic_credentials=None, bearer_credentials=None, api_key=None)) + _run_async( + dep(basic_credentials=None, bearer_credentials=None, api_key=None) + ) assert exc_info.value.detail == "Invalid authentication credentials" def test_returns_none_when_depends_unavailable(self) -> None: @@ -629,32 +699,42 @@ def test_optional_false_with_valid_basic_does_not_raise(self) -> None: basic_creds.password = "pass" # Should not raise - result = _run_async(dep(basic_credentials=basic_creds, bearer_credentials=None, api_key=None)) - assert result['authenticated'] is True + result = _run_async( + dep(basic_credentials=basic_creds, bearer_credentials=None, api_key=None) + ) + assert result["authenticated"] is True # =========================================================================== # flask_decorator -- auth enforcement # =========================================================================== + class TestFlaskDecorator: """Test the Flask decorator for authentication.""" def _make_handler(self, **kwargs: Any) -> "AuthHandler": from signalwire.core.auth_handler import AuthHandler + cfg = _make_security_config(**kwargs) return AuthHandler(cfg) - def _mock_flask_request(self, auth_header: str | None = None, authorization: Any = None, api_key_header: str | None = None, api_key_value: str | None = None) -> Mock: + def _mock_flask_request( + self, + auth_header: str | None = None, + authorization: Any = None, + api_key_header: str | None = None, + api_key_value: str | None = None, + ) -> Mock: """Build a mock flask request object.""" request = Mock() headers = {} if auth_header: - headers['Authorization'] = auth_header + headers["Authorization"] = auth_header if api_key_header and api_key_value: headers[api_key_header] = api_key_value request.headers = Mock() - request.headers.get = lambda key, default='': headers.get(key, default) + request.headers.get = lambda key, default="": headers.get(key, default) request.authorization = authorization request.remote_addr = "127.0.0.1" request.method = "POST" @@ -686,7 +766,9 @@ def test_api_key_success(self) -> None: def my_view() -> str: return "OK" - mock_request = self._mock_flask_request(api_key_header="X-API-Key", api_key_value="ak_secret") + mock_request = self._mock_flask_request( + api_key_header="X-API-Key", api_key_value="ak_secret" + ) mock_flask = Mock() mock_flask.request = mock_request mock_flask.Response = Mock(return_value="401 response") @@ -718,8 +800,7 @@ def my_view() -> str: def test_all_methods_fail_returns_401(self) -> None: """Flask decorator returns 401 when all auth methods fail.""" handler = self._make_handler( - username="admin", password="pass", - bearer_token="tok", api_key="ak" + username="admin", password="pass", bearer_token="tok", api_key="ak" ) @handler.flask_decorator @@ -737,9 +818,9 @@ def my_view() -> str: assert result is mock_response_instance mock_flask.Response.assert_called_once_with( - 'Authentication required', + "Authentication required", 401, - {'WWW-Authenticate': 'Basic realm="SignalWire Service"'} + {"WWW-Authenticate": 'Basic realm="SignalWire Service"'}, ) def test_wrong_bearer_wrong_basic_returns_401(self) -> None: @@ -811,12 +892,17 @@ def my_view() -> str: mock_flask.request = mock_request mock_flask.Response = Mock(return_value="401 resp") - with patch.dict("sys.modules", {"flask": mock_flask}): - with patch("signalwire.core.auth_handler.secrets.compare_digest", wraps=secrets.compare_digest) as mock_cd: - result = my_view() - assert result == "OK" - # Should have been called for the bearer comparison - mock_cd.assert_any_call("tok_safe", "tok_safe") + with ( + patch.dict("sys.modules", {"flask": mock_flask}), + patch( + "signalwire.core.auth_handler.secrets.compare_digest", + wraps=secrets.compare_digest, + ) as mock_cd, + ): + result = my_view() + assert result == "OK" + # Should have been called for the bearer comparison + mock_cd.assert_any_call("tok_safe", "tok_safe") def test_basic_auth_no_authorization_header(self) -> None: """When authorization is None, basic auth falls through gracefully.""" @@ -864,8 +950,10 @@ def my_view() -> str: def test_api_key_priority_over_basic(self) -> None: """API key auth is checked before basic auth in the Flask decorator.""" handler = self._make_handler( - username="admin", password="pass", - api_key="ak_123", api_key_header="X-API-Key" + username="admin", + password="pass", + api_key="ak_123", + api_key_header="X-API-Key", ) @handler.flask_decorator @@ -874,8 +962,7 @@ def my_view() -> str: # Provide only API key, no basic auth mock_request = self._mock_flask_request( - api_key_header="X-API-Key", - api_key_value="ak_123" + api_key_header="X-API-Key", api_key_value="ak_123" ) mock_flask = Mock() mock_flask.request = mock_request @@ -890,11 +977,13 @@ def my_view() -> str: # get_auth_info # =========================================================================== + class TestGetAuthInfo: """Test the get_auth_info method.""" def _make_handler(self, **kwargs: Any) -> "AuthHandler": from signalwire.core.auth_handler import AuthHandler + cfg = _make_security_config(**kwargs) return AuthHandler(cfg) @@ -903,81 +992,80 @@ def test_basic_only(self) -> None: handler = self._make_handler(username="admin", password="pass") info = handler.get_auth_info() - assert 'basic' in info - assert info['basic']['enabled'] is True - assert info['basic']['username'] == "admin" - assert 'bearer' not in info - assert 'api_key' not in info + assert "basic" in info + assert info["basic"]["enabled"] is True + assert info["basic"]["username"] == "admin" + assert "bearer" not in info + assert "api_key" not in info def test_bearer_info(self) -> None: """Bearer info is included when bearer token is configured.""" handler = self._make_handler(bearer_token="tok") info = handler.get_auth_info() - assert 'bearer' in info - assert info['bearer']['enabled'] is True - assert 'hint' in info['bearer'] + assert "bearer" in info + assert info["bearer"]["enabled"] is True + assert "hint" in info["bearer"] def test_api_key_info(self) -> None: """API key info includes header name and hint.""" handler = self._make_handler(api_key="ak", api_key_header="X-Custom") info = handler.get_auth_info() - assert 'api_key' in info - assert info['api_key']['enabled'] is True - assert info['api_key']['header'] == "X-Custom" - assert "X-Custom" in info['api_key']['hint'] + assert "api_key" in info + assert info["api_key"]["enabled"] is True + assert info["api_key"]["header"] == "X-Custom" + assert "X-Custom" in info["api_key"]["hint"] def test_all_methods_info(self) -> None: """All three methods present in info when all configured.""" handler = self._make_handler( - username="u", password="p", - bearer_token="tok", api_key="ak" + username="u", password="p", bearer_token="tok", api_key="ak" ) info = handler.get_auth_info() - assert set(info.keys()) == {'basic', 'bearer', 'api_key'} + assert set(info.keys()) == {"basic", "bearer", "api_key"} def test_disabled_methods_excluded(self) -> None: """Disabled auth methods are excluded from the info dict.""" handler = self._make_handler( - username="u", password="p", - bearer_token="tok", api_key="ak" + username="u", password="p", bearer_token="tok", api_key="ak" ) - handler.auth_methods['bearer']['enabled'] = False - handler.auth_methods['api_key']['enabled'] = False + handler.auth_methods["bearer"]["enabled"] = False + handler.auth_methods["api_key"]["enabled"] = False info = handler.get_auth_info() - assert 'basic' in info - assert 'bearer' not in info - assert 'api_key' not in info + assert "basic" in info + assert "bearer" not in info + assert "api_key" not in info def test_no_password_leak_in_info(self) -> None: """get_auth_info does not leak password or tokens in the returned dict.""" handler = self._make_handler( - username="u", password="supersecret", - bearer_token="hidden_tok", api_key="hidden_ak" + username="u", + password="supersecret", + bearer_token="hidden_tok", + api_key="hidden_ak", ) info = handler.get_auth_info() # Password should not be in basic info - assert 'password' not in info.get('basic', {}) - assert 'supersecret' not in str(info) + assert "password" not in info.get("basic", {}) + assert "supersecret" not in str(info) # Token should not be in bearer info - assert 'token' not in info.get('bearer', {}) - assert 'hidden_tok' not in str(info) + assert "token" not in info.get("bearer", {}) + assert "hidden_tok" not in str(info) # API key should not be in api_key info - assert 'key' not in info.get('api_key', {}) - assert 'hidden_ak' not in str(info) + assert "key" not in info.get("api_key", {}) + assert "hidden_ak" not in str(info) def test_empty_info_when_all_disabled(self) -> None: """An empty dict is returned when all auth methods are disabled.""" handler = self._make_handler( - username="u", password="p", - bearer_token="tok", api_key="ak" + username="u", password="p", bearer_token="tok", api_key="ak" ) - handler.auth_methods['basic']['enabled'] = False - handler.auth_methods['bearer']['enabled'] = False - handler.auth_methods['api_key']['enabled'] = False + handler.auth_methods["basic"]["enabled"] = False + handler.auth_methods["bearer"]["enabled"] = False + handler.auth_methods["api_key"]["enabled"] = False info = handler.get_auth_info() assert info == {} @@ -987,6 +1075,7 @@ def test_empty_info_when_all_disabled(self) -> None: # Edge cases and credential-related scenarios # =========================================================================== + class TestEdgeCases: """Edge cases around credentials and handler behavior.""" @@ -1008,7 +1097,9 @@ def test_credentials_with_special_characters(self) -> None: """Special characters (colons, slashes, etc.) in credentials.""" from signalwire.core.auth_handler import AuthHandler - cfg = _make_security_config(username="user:with:colons", password="p@ss/w0rd!#$%") + cfg = _make_security_config( + username="user:with:colons", password="p@ss/w0rd!#$%" + ) handler = AuthHandler(cfg) creds = Mock() @@ -1051,8 +1142,8 @@ def test_setup_auth_methods_called_on_init(self) -> None: from signalwire.core.auth_handler import AuthHandler cfg = _make_security_config(username="u", password="p") - with patch.object(AuthHandler, '_setup_auth_methods') as mock_setup: - handler = AuthHandler(cfg) + with patch.object(AuthHandler, "_setup_auth_methods") as mock_setup: + AuthHandler(cfg) mock_setup.assert_called_once() def test_handler_stores_security_config(self) -> None: @@ -1086,28 +1177,30 @@ def test_multiple_handler_instances_independent(self) -> None: from signalwire.core.auth_handler import AuthHandler cfg1 = _make_security_config(username="user1", password="pass1") - cfg2 = _make_security_config(username="user2", password="pass2", bearer_token="tok") + cfg2 = _make_security_config( + username="user2", password="pass2", bearer_token="tok" + ) handler1 = AuthHandler(cfg1) handler2 = AuthHandler(cfg2) - assert handler1.auth_methods['basic']['username'] == "user1" - assert handler2.auth_methods['basic']['username'] == "user2" - assert 'bearer' not in handler1.auth_methods - assert 'bearer' in handler2.auth_methods + assert handler1.auth_methods["basic"]["username"] == "user1" + assert handler2.auth_methods["basic"]["username"] == "user2" + assert "bearer" not in handler1.auth_methods + assert "bearer" in handler2.auth_methods def test_api_key_header_defaults_when_attr_missing(self) -> None: """When security_config has no api_key_header attr, defaults to X-API-Key.""" from signalwire.core.auth_handler import AuthHandler # Use Mock with spec to ensure api_key_header is not an attribute - cfg = Mock(spec=['get_basic_auth', 'bearer_token', 'api_key']) + cfg = Mock(spec=["get_basic_auth", "bearer_token", "api_key"]) cfg.get_basic_auth.return_value = ("u", "p") cfg.bearer_token = None cfg.api_key = "thekey" handler = AuthHandler(cfg) - assert handler.auth_methods['api_key']['header'] == "X-API-Key" + assert handler.auth_methods["api_key"]["header"] == "X-API-Key" def test_whitespace_credentials_not_stripped(self) -> None: """Leading/trailing whitespace in credentials is significant.""" @@ -1127,7 +1220,7 @@ def test_null_bearer_token_in_auth_methods(self) -> None: cfg = _make_security_config(bearer_token="tok") handler = AuthHandler(cfg) - handler.auth_methods['bearer']['enabled'] = False + handler.auth_methods["bearer"]["enabled"] = False creds = Mock() creds.credentials = "tok" @@ -1140,8 +1233,8 @@ def test_handler_basic_auth_has_basic_auth_and_bearer_fields(self) -> None: cfg = _make_security_config() handler = AuthHandler(cfg) - assert hasattr(handler, 'basic_auth') - assert hasattr(handler, 'bearer_auth') + assert hasattr(handler, "basic_auth") + assert hasattr(handler, "bearer_auth") def test_handler_with_none_httpbasic(self) -> None: """When HTTPBasic is None (not installed), basic_auth attribute is None.""" @@ -1168,3 +1261,134 @@ def test_handler_with_none_httpbearer(self) -> None: assert handler.bearer_auth is None finally: auth_handler.HTTPBearer = original # type: ignore[attr-defined] # conditionally-defined module attr + + +# =========================================================================== +# Credential carriers: the framework-free parameter types +# =========================================================================== + + +class TestCredentialCarriers: + """The credential Protocols accept a real FastAPI object and a duck type. + + ``verify_basic_auth`` / ``verify_bearer_token`` used to annotate their + parameter with FastAPI's concrete credentials classes. They are now declared + structurally, which is strictly WIDER — so every shape that worked before must + still work. These tests pin that. + """ + + def _handler( + self, + username: str = _CARRIER_USER, + password: str = _CARRIER_PASSWORD, + token: str = _CARRIER_TOKEN, + ) -> "AuthHandler": + from signalwire.core.auth_handler import AuthHandler + + cfg = _make_security_config( + username=username, password=password, bearer_token=token + ) + return AuthHandler(cfg) + + def test_real_fastapi_basic_credentials_still_verify(self) -> None: + """A genuine FastAPI HTTPBasicCredentials must keep working unchanged.""" + from fastapi.security import HTTPBasicCredentials + + handler = self._handler() + assert ( + handler.verify_basic_auth( + HTTPBasicCredentials(username="admin", password="secret") + ) + is True + ) + assert ( + handler.verify_basic_auth( + HTTPBasicCredentials(username="admin", password="wrong") + ) + is False + ) + + def test_real_fastapi_bearer_credentials_still_verify(self) -> None: + """A genuine FastAPI HTTPAuthorizationCredentials must keep working.""" + from fastapi.security import HTTPAuthorizationCredentials + + handler = self._handler() + assert ( + handler.verify_bearer_token( + HTTPAuthorizationCredentials(scheme="Bearer", credentials="tok") + ) + is True + ) + assert ( + handler.verify_bearer_token( + HTTPAuthorizationCredentials(scheme="Bearer", credentials="nope") + ) + is False + ) + + def test_duck_typed_object_verifies(self) -> None: + """Any object carrying the fields satisfies the structural parameter type.""" + + class DuckBasic: + username = "admin" + password = "secret" + + class DuckBearer: + scheme = "Bearer" + credentials = "tok" + + handler = self._handler() + assert handler.verify_basic_auth(DuckBasic()) is True + assert handler.verify_bearer_token(DuckBearer()) is True + + def test_plain_object_carriers_verify(self) -> None: + """A minimal framework-free carrier — what a non-FastAPI caller would build.""" + from dataclasses import dataclass + + @dataclass + class Basic: + username: str + password: str + + @dataclass + class Bearer: + scheme: str + credentials: str + + handler = self._handler() + assert handler.verify_basic_auth(Basic("admin", "secret")) is True + assert handler.verify_basic_auth(Basic("admin", "no")) is False + assert handler.verify_bearer_token(Bearer("Bearer", "tok")) is True + assert handler.verify_bearer_token(Bearer("Bearer", "no")) is False + + def test_real_fastapi_objects_satisfy_the_protocols(self) -> None: + """The Protocols are wider than the FastAPI classes they replaced.""" + from fastapi.security import HTTPAuthorizationCredentials, HTTPBasicCredentials + from signalwire.core.auth_handler import BasicCredentials, BearerCredentials + + assert isinstance( + HTTPBasicCredentials(username="u", password="p"), BasicCredentials + ) + assert isinstance( + HTTPAuthorizationCredentials(scheme="Bearer", credentials="t"), + BearerCredentials, + ) + + def test_protocols_declare_the_documented_fields(self) -> None: + """scheme is part of the carrier even though only credentials is compared.""" + from signalwire.core.auth_handler import BasicCredentials, BearerCredentials + + assert set(BasicCredentials.__annotations__) == {"username", "password"} + assert set(BearerCredentials.__annotations__) == {"scheme", "credentials"} + + def test_bearer_verification_ignores_the_scheme(self) -> None: + """Only `credentials` is compared — scheme is carried, never matched.""" + from dataclasses import dataclass + + @dataclass + class Bearer: + scheme: str + credentials: str + + handler = self._handler() + assert handler.verify_bearer_token(Bearer("Anything", "tok")) is True diff --git a/tests/unit/core/test_contexts.py b/tests/unit/core/test_contexts.py index 6335c468..43e6f2da 100644 --- a/tests/unit/core/test_contexts.py +++ b/tests/unit/core/test_contexts.py @@ -12,8 +12,7 @@ """ import pytest -from unittest.mock import Mock, patch, MagicMock -from typing import Dict, List, Any, Optional +from unittest.mock import Mock from signalwire.core.contexts import ( ContextBuilder, @@ -21,155 +20,159 @@ Step, GatherInfo, GatherQuestion, - create_simple_context + create_simple_context, ) class TestStep: """Test Step functionality""" - + def test_basic_initialization(self) -> None: """Test basic Step initialization""" step = Step("greeting") - + assert step.name == "greeting" assert step._text is None assert step._step_criteria is None assert step._functions is None assert step._valid_steps is None assert step._sections == [] - + def test_set_text(self) -> None: """Test setting step text""" step = Step("greeting") - + result = step.set_text("Hello, how can I help you today?") - + assert result is step # Should return self for chaining assert step._text == "Hello, how can I help you today?" - + def test_add_section(self) -> None: """Test adding POM sections""" step = Step("greeting") - + result = step.add_section("Introduction", "Welcome to our service") - + assert result is step # Should return self for chaining assert len(step._sections) == 1 assert step._sections[0]["title"] == "Introduction" assert step._sections[0]["body"] == "Welcome to our service" - + def test_add_bullets(self) -> None: """Test adding bullet sections""" step = Step("greeting") bullets = ["First point", "Second point", "Third point"] - + result = step.add_bullets("Key Points", bullets) - + assert result is step # Should return self for chaining assert len(step._sections) == 1 assert step._sections[0]["title"] == "Key Points" assert step._sections[0]["bullets"] == bullets - + def test_set_step_criteria(self) -> None: """Test setting step criteria""" step = Step("greeting") - + result = step.set_step_criteria("User has provided their name") - + assert result is step # Should return self for chaining assert step._step_criteria == "User has provided their name" - + def test_set_functions(self) -> None: """Test setting available functions""" step = Step("greeting") - + # Test with function list result = step.set_functions(["get_weather", "search"]) assert result is step assert step._functions == ["get_weather", "search"] - + # Test with "none" step.set_functions("none") assert step._functions == "none" # type: ignore[comparison-overlap] # testing "none" sentinel - + def test_set_valid_steps(self) -> None: """Test setting valid steps""" step = Step("greeting") valid_steps = ["next", "collect_info", "end"] - + result = step.set_valid_steps(valid_steps) - + assert result is step # Should return self for chaining assert step._valid_steps == valid_steps - + def test_text_and_sections_conflict(self) -> None: """Test that text and sections cannot be mixed""" step = Step("greeting") - + # Set text first step.set_text("Hello") - + # Adding sections should raise error with pytest.raises(ValueError, match="Cannot add POM sections when set_text"): step.add_section("Title", "Body") - + with pytest.raises(ValueError, match="Cannot add POM sections when set_text"): step.add_bullets("Title", ["bullet"]) - + def test_sections_and_text_conflict(self) -> None: """Test that sections and text cannot be mixed""" step = Step("greeting") - + # Add section first step.add_section("Title", "Body") - + # Setting text should raise error - with pytest.raises(ValueError, match="Cannot use set_text\\(\\) when POM sections"): + with pytest.raises( + ValueError, match="Cannot use set_text\\(\\) when POM sections" + ): step.set_text("Hello") - + def test_render_text_with_text(self) -> None: """Test rendering text when text is set""" step = Step("greeting") step.set_text("Hello, how can I help you?") - + rendered = step._render_text() - + assert rendered == "Hello, how can I help you?" - + def test_render_text_with_sections(self) -> None: """Test rendering text from POM sections""" step = Step("greeting") step.add_section("Welcome", "Hello there!") step.add_bullets("Options", ["Option 1", "Option 2"]) - + rendered = step._render_text() - + assert "## Welcome" in rendered assert "Hello there!" in rendered assert "## Options" in rendered assert "- Option 1" in rendered assert "- Option 2" in rendered - + def test_render_text_no_content(self) -> None: """Test rendering text when no content is set""" step = Step("greeting") - - with pytest.raises(ValueError, match="Step 'greeting' has no text or POM sections"): + + with pytest.raises( + ValueError, match="Step 'greeting' has no text or POM sections" + ): step._render_text() - + def test_to_dict_basic(self) -> None: """Test converting step to dictionary""" step = Step("greeting") step.set_text("Hello!") - + result = step.to_dict() - + assert result["text"] == "Hello!" assert "step_criteria" not in result assert "functions" not in result assert "valid_steps" not in result - + def test_to_dict_complete(self) -> None: """Test converting step with all fields to dictionary""" step = Step("greeting") @@ -177,9 +180,9 @@ def test_to_dict_complete(self) -> None: step.set_step_criteria("User responds") step.set_functions(["search"]) step.set_valid_steps(["next"]) - + result = step.to_dict() - + assert result["text"] == "Hello!" assert result["step_criteria"] == "User responds" assert result["functions"] == ["search"] @@ -188,122 +191,124 @@ def test_to_dict_complete(self) -> None: class TestContext: """Test Context functionality""" - + def test_basic_initialization(self) -> None: """Test basic Context initialization""" context = Context("customer_service") - + assert context.name == "customer_service" assert context._steps == {} assert context._step_order == [] assert context._valid_contexts is None - + def test_add_step(self) -> None: """Test adding steps to context""" context = Context("customer_service") - + step = context.add_step("greeting") - + assert isinstance(step, Step) assert step.name == "greeting" assert "greeting" in context._steps assert context._step_order == ["greeting"] - + def test_add_multiple_steps(self) -> None: """Test adding multiple steps""" context = Context("customer_service") - + step1 = context.add_step("greeting") step2 = context.add_step("collect_info") step3 = context.add_step("provide_solution") - + assert len(context._steps) == 3 assert context._step_order == ["greeting", "collect_info", "provide_solution"] assert all(isinstance(step, Step) for step in [step1, step2, step3]) - + def test_add_duplicate_step(self) -> None: """Test adding duplicate step names""" context = Context("customer_service") - + context.add_step("greeting") - + with pytest.raises(ValueError, match="Step 'greeting' already exists"): context.add_step("greeting") - + def test_set_valid_contexts(self) -> None: """Test setting valid contexts""" context = Context("customer_service") valid_contexts = ["sales", "technical_support"] - + result = context.set_valid_contexts(valid_contexts) - + assert result is context # Should return self for chaining assert context._valid_contexts == valid_contexts - + def test_to_dict_basic(self) -> None: """Test converting context to dictionary""" context = Context("customer_service") step = context.add_step("greeting") step.set_text("Hello!") - + result = context.to_dict() - + assert "steps" in result assert len(result["steps"]) == 1 assert result["steps"][0]["text"] == "Hello!" assert "valid_contexts" not in result - + def test_to_dict_with_valid_contexts(self) -> None: """Test converting context with valid contexts""" context = Context("customer_service") step = context.add_step("greeting") step.set_text("Hello!") context.set_valid_contexts(["sales"]) - + result = context.to_dict() - + assert "steps" in result assert result["valid_contexts"] == ["sales"] - + def test_to_dict_no_steps(self) -> None: """Test converting context with no steps""" context = Context("customer_service") - + with pytest.raises(ValueError, match="Context 'customer_service' has no steps"): context.to_dict() class TestContextBuilder: """Test ContextBuilder functionality""" - + def test_basic_initialization(self) -> None: """Test basic ContextBuilder initialization""" mock_agent = Mock() builder = ContextBuilder(mock_agent) - + # ContextBuilder doesn't store agent reference, just uses it during init assert builder._contexts == {} - + def test_add_context(self) -> None: """Test adding a context""" mock_agent = Mock() builder = ContextBuilder(mock_agent) - + context = builder.add_context("customer_service") assert isinstance(context, Context) assert "customer_service" in builder._contexts - + def test_add_duplicate_context(self) -> None: """Test adding duplicate context raises error""" mock_agent = Mock() builder = ContextBuilder(mock_agent) - + builder.add_context("customer_service") - + # The actual API raises an error for duplicates - with pytest.raises(ValueError, match="Context 'customer_service' already exists"): + with pytest.raises( + ValueError, match="Context 'customer_service' already exists" + ): builder.add_context("customer_service") - + def test_validate_success(self) -> None: """Test successful validation with default context — returns None and the validated config is reachable via to_dict() with the right @@ -311,7 +316,9 @@ def test_validate_success(self) -> None: mock_agent = Mock() builder = ContextBuilder(mock_agent) - context = builder.add_context("default") # Must be named 'default' for single context + context = builder.add_context( + "default" + ) # Must be named 'default' for single context step = context.add_step("greeting") step.set_text("Hello!") @@ -325,34 +332,38 @@ def test_validate_success(self) -> None: # named step survives the round trip. step_names = [s["name"] for s in d["default"]["steps"]] assert "greeting" in step_names - + def test_validate_no_contexts(self) -> None: """Test validation with no contexts""" mock_agent = Mock() builder = ContextBuilder(mock_agent) - + with pytest.raises(ValueError, match="At least one context must be defined"): builder.validate() - + def test_validate_context_no_steps(self) -> None: """Test validation with context having no steps""" mock_agent = Mock() builder = ContextBuilder(mock_agent) - + builder.add_context("default") # Must be named 'default' for single context - - with pytest.raises(ValueError, match="Context 'default' must have at least one step"): + + with pytest.raises( + ValueError, match="Context 'default' must have at least one step" + ): builder.validate() - + def test_to_dict(self) -> None: """Test converting builder to dictionary""" mock_agent = Mock() builder = ContextBuilder(mock_agent) - - context = builder.add_context("default") # Must be named 'default' for single context + + context = builder.add_context( + "default" + ) # Must be named 'default' for single context step = context.add_step("greeting") step.set_text("Hello!") - + result = builder.to_dict() assert isinstance(result, dict) assert "default" in result @@ -360,118 +371,128 @@ def test_to_dict(self) -> None: class TestCreateSimpleContext: """Test create_simple_context factory function""" - + def test_create_simple_context_default(self) -> None: """Test creating simple context with default name""" context = create_simple_context() - + assert isinstance(context, Context) assert context.name == "default" - + def test_create_simple_context_custom_name(self) -> None: """Test creating simple context with custom name""" context = create_simple_context("my_context") - + assert isinstance(context, Context) assert context.name == "my_context" class TestContextIntegration: """Test context integration scenarios""" - + def test_complete_context_workflow(self) -> None: """Test complete context building workflow with multiple contexts""" mock_agent = Mock() builder = ContextBuilder(mock_agent) - + # Create customer service context customer_service = builder.add_context("customer_service") customer_service.set_valid_contexts(["sales", "technical_support"]) - + # Add greeting step greeting = customer_service.add_step("greeting") - greeting.set_text("Hello! Welcome to customer service. How can I help you today?") + greeting.set_text( + "Hello! Welcome to customer service. How can I help you today?" + ) greeting.set_step_criteria("User has stated their issue") greeting.set_functions(["search_knowledge_base", "escalate_to_human"]) greeting.set_valid_steps(["next", "gather_info"]) # Use valid step names - + # Add information gathering step gather_info = customer_service.add_step("gather_info") - gather_info.add_section("Information Needed", "Please provide the following details:") - gather_info.add_bullets("Required Information", [ - "Account number or phone number", - "Description of the issue", - "When did the issue start?" - ]) + gather_info.add_section( + "Information Needed", "Please provide the following details:" + ) + gather_info.add_bullets( + "Required Information", + [ + "Account number or phone number", + "Description of the issue", + "When did the issue start?", + ], + ) gather_info.set_step_criteria("All required information has been collected") gather_info.set_valid_steps(["next", "greeting"]) - + # Add resolution step resolution = customer_service.add_step("resolution") - resolution.set_text("Based on the information provided, here's how we can resolve your issue:") + resolution.set_text( + "Based on the information provided, here's how we can resolve your issue:" + ) resolution.set_functions("none") # No functions needed for final step - + # Add the referenced contexts to satisfy validation sales = builder.add_context("sales") sales_step = sales.add_step("sales_greeting") sales_step.set_text("Welcome to sales!") - + technical_support = builder.add_context("technical_support") tech_step = technical_support.add_step("tech_greeting") tech_step.set_text("Welcome to technical support!") - + # Validate the complete structure builder.validate() - + # Convert to dictionary result = builder.to_dict() assert "customer_service" in result assert "sales" in result assert "technical_support" in result assert len(result["customer_service"]["steps"]) == 3 - + def test_multiple_contexts(self) -> None: """Test building multiple contexts""" mock_agent = Mock() builder = ContextBuilder(mock_agent) - + # Create sales context sales = builder.add_context("sales") sales_step = sales.add_step("pitch") sales_step.set_text("Let me tell you about our amazing products!") - + # Create support context support = builder.add_context("support") support_step = support.add_step("diagnose") support_step.set_text("Let's troubleshoot your issue.") - + # Validate and convert builder.validate() result = builder.to_dict() - + # Verify both contexts exist assert "sales" in result assert "support" in result assert len(result["sales"]["steps"]) == 1 assert len(result["support"]["steps"]) == 1 - + def test_complex_step_configuration(self) -> None: """Test complex step configuration with all features""" context = Context("complex") - + step = context.add_step("complex_step") - + # Use method chaining - step.add_section("Overview", "This is a complex step with multiple sections") \ - .add_bullets("Features", ["Feature 1", "Feature 2", "Feature 3"]) \ - .add_section("Instructions", "Follow these steps carefully") \ - .set_step_criteria("All features have been demonstrated") \ - .set_functions(["demo_feature_1", "demo_feature_2", "demo_feature_3"]) \ - .set_valid_steps(["next", "previous", "help"]) - + step.add_section( + "Overview", "This is a complex step with multiple sections" + ).add_bullets("Features", ["Feature 1", "Feature 2", "Feature 3"]).add_section( + "Instructions", "Follow these steps carefully" + ).set_step_criteria("All features have been demonstrated").set_functions( + ["demo_feature_1", "demo_feature_2", "demo_feature_3"] + ).set_valid_steps(["next", "previous", "help"]) + # Convert to dict and verify step_dict = step.to_dict() - + # Check that all sections are rendered text = step_dict["text"] assert "## Overview" in text @@ -480,10 +501,14 @@ def test_complex_step_configuration(self) -> None: assert "- Feature 1" in text assert "## Instructions" in text assert "Follow these steps" in text - + # Check other fields assert step_dict["step_criteria"] == "All features have been demonstrated" - assert step_dict["functions"] == ["demo_feature_1", "demo_feature_2", "demo_feature_3"] + assert step_dict["functions"] == [ + "demo_feature_1", + "demo_feature_2", + "demo_feature_3", + ] assert step_dict["valid_steps"] == ["next", "previous", "help"] @@ -636,7 +661,9 @@ def test_add_system_section_conflict_with_set_system_prompt(self) -> None: """Test that add_system_section raises when set_system_prompt already used""" context = Context("sales") context.set_system_prompt("You are a sales agent.") - with pytest.raises(ValueError, match="Cannot add POM sections for system prompt"): + with pytest.raises( + ValueError, match="Cannot add POM sections for system prompt" + ): context.add_system_section("Role", "You are a sales agent.") def test_add_system_bullets(self) -> None: @@ -645,13 +672,18 @@ def test_add_system_bullets(self) -> None: result = context.add_system_bullets("Rules", ["Be polite", "Be helpful"]) assert result is context assert len(context._system_prompt_sections) == 1 - assert context._system_prompt_sections[0]["bullets"] == ["Be polite", "Be helpful"] + assert context._system_prompt_sections[0]["bullets"] == [ + "Be polite", + "Be helpful", + ] def test_add_system_bullets_conflict_with_set_system_prompt(self) -> None: """Test that add_system_bullets raises when set_system_prompt already used""" context = Context("sales") context.set_system_prompt("You are a sales agent.") - with pytest.raises(ValueError, match="Cannot add POM sections for system prompt"): + with pytest.raises( + ValueError, match="Cannot add POM sections for system prompt" + ): context.add_system_bullets("Rules", ["Be polite"]) def test_render_system_prompt_with_text(self) -> None: @@ -958,7 +990,9 @@ def test_validate_single_context_not_named_default(self) -> None: builder = ContextBuilder(mock_agent) context = builder.add_context("custom_name") context.add_step("greeting").set_text("Hello!") - with pytest.raises(ValueError, match="single context, it must be named 'default'"): + with pytest.raises( + ValueError, match="single context, it must be named 'default'" + ): builder.validate() def test_validate_invalid_step_reference(self) -> None: @@ -969,7 +1003,9 @@ def test_validate_invalid_step_reference(self) -> None: step = context.add_step("greeting") step.set_text("Hello!") step.set_valid_steps(["nonexistent_step"]) - with pytest.raises(ValueError, match="references unknown step 'nonexistent_step'"): + with pytest.raises( + ValueError, match="references unknown step 'nonexistent_step'" + ): builder.validate() def test_validate_next_is_allowed_in_valid_steps(self) -> None: @@ -997,7 +1033,10 @@ def test_validate_invalid_context_reference_at_context_level(self) -> None: ctx1.set_valid_contexts(["nonexistent_context"]) ctx2 = builder.add_context("ctx2") ctx2.add_step("s2").set_text("Hi!") - with pytest.raises(ValueError, match="Context 'ctx1' references unknown context 'nonexistent_context'"): + with pytest.raises( + ValueError, + match="Context 'ctx1' references unknown context 'nonexistent_context'", + ): builder.validate() def test_validate_invalid_context_reference_at_step_level(self) -> None: @@ -1010,7 +1049,9 @@ def test_validate_invalid_context_reference_at_step_level(self) -> None: step.set_valid_contexts(["nonexistent_context"]) ctx2 = builder.add_context("ctx2") ctx2.add_step("s2").set_text("Hi!") - with pytest.raises(ValueError, match="references unknown context 'nonexistent_context'"): + with pytest.raises( + ValueError, match="references unknown context 'nonexistent_context'" + ): builder.validate() def test_validate_valid_context_references(self) -> None: @@ -1050,6 +1091,7 @@ def test_to_dict_preserves_order(self) -> None: # GatherInfo / GatherQuestion # --------------------------------------------------------------------------- + class TestGatherQuestion: """Test GatherQuestion class""" @@ -1060,8 +1102,12 @@ def test_basic_question(self) -> None: def test_question_with_all_fields(self) -> None: q = GatherQuestion( - key="email", question="Email?", type="string", - confirm=True, prompt="Be precise", functions=["validate_email"] + key="email", + question="Email?", + type="string", + confirm=True, + prompt="Be precise", + functions=["validate_email"], ) d = q.to_dict() assert d["key"] == "email" @@ -1089,8 +1135,9 @@ def test_basic_gather_info(self) -> None: assert len(d["questions"]) == 1 def test_gather_info_with_all_params(self) -> None: - gi = GatherInfo(output_key="profile", completion_action="next_step", - prompt="Welcome!") + gi = GatherInfo( + output_key="profile", completion_action="next_step", prompt="Welcome!" + ) gi.add_question("name", "Name?") d = gi.to_dict() assert d["output_key"] == "profile" @@ -1153,10 +1200,9 @@ def test_next_step_valid_when_following_step_exists(self) -> None: following step in the same context, validate() must accept it.""" builder = self._make_builder() ctx = builder.add_context("default") - ctx.add_step("gather") \ - .set_text("Gather") \ - .set_gather_info(completion_action="next_step") \ - .add_gather_question("name", "Name?") + ctx.add_step("gather").set_text("Gather").set_gather_info( + completion_action="next_step" + ).add_gather_question("name", "Name?") ctx.add_step("process").set_text("Process") # validate() returns None and to_dict() preserves the action verbatim. assert builder.validate() is None # type: ignore[func-returns-value] # validate() returns None; asserting it ran without raising @@ -1167,10 +1213,9 @@ def test_next_step_valid_when_following_step_exists(self) -> None: def test_next_step_invalid_on_last_step(self) -> None: builder = self._make_builder() ctx = builder.add_context("default") - ctx.add_step("only_step") \ - .set_text("Gather") \ - .set_gather_info(completion_action="next_step") \ - .add_gather_question("name", "Name?") + ctx.add_step("only_step").set_text("Gather").set_gather_info( + completion_action="next_step" + ).add_gather_question("name", "Name?") with pytest.raises(ValueError, match="last step"): builder.validate() @@ -1180,10 +1225,9 @@ def test_named_step_valid(self) -> None: accept it.""" builder = self._make_builder() ctx = builder.add_context("default") - ctx.add_step("gather") \ - .set_text("Gather") \ - .set_gather_info(completion_action="review") \ - .add_gather_question("name", "Name?") + ctx.add_step("gather").set_text("Gather").set_gather_info( + completion_action="review" + ).add_gather_question("name", "Name?") ctx.add_step("middle").set_text("Middle") ctx.add_step("review").set_text("Review") assert builder.validate() is None # type: ignore[func-returns-value] # validate() returns None; asserting it ran without raising @@ -1197,10 +1241,9 @@ def test_named_step_valid(self) -> None: def test_named_step_invalid_when_not_defined(self) -> None: builder = self._make_builder() ctx = builder.add_context("default") - ctx.add_step("gather") \ - .set_text("Gather") \ - .set_gather_info(completion_action="nonexistent") \ - .add_gather_question("name", "Name?") + ctx.add_step("gather").set_text("Gather").set_gather_info( + completion_action="nonexistent" + ).add_gather_question("name", "Name?") ctx.add_step("other").set_text("Other") with pytest.raises(ValueError, match="is not a step in this context"): builder.validate() @@ -1212,10 +1255,9 @@ def test_no_completion_action_always_valid(self) -> None: completion_action key.""" builder = self._make_builder() ctx = builder.add_context("default") - ctx.add_step("only_step") \ - .set_text("Gather") \ - .set_gather_info() \ - .add_gather_question("name", "Name?") + ctx.add_step("only_step").set_text( + "Gather" + ).set_gather_info().add_gather_question("name", "Name?") assert builder.validate() is None # type: ignore[func-returns-value] # validate() returns None; asserting it ran without raising d = builder.to_dict() only_step = d["default"]["steps"][0] @@ -1228,14 +1270,12 @@ def test_next_step_valid_not_last_in_multi_step(self) -> None: because each has a following step.""" builder = self._make_builder() ctx = builder.add_context("default") - ctx.add_step("step1") \ - .set_text("S1") \ - .set_gather_info(completion_action="next_step") \ - .add_gather_question("a", "Q?") - ctx.add_step("step2") \ - .set_text("S2") \ - .set_gather_info(completion_action="next_step") \ - .add_gather_question("b", "Q?") + ctx.add_step("step1").set_text("S1").set_gather_info( + completion_action="next_step" + ).add_gather_question("a", "Q?") + ctx.add_step("step2").set_text("S2").set_gather_info( + completion_action="next_step" + ).add_gather_question("b", "Q?") ctx.add_step("step3").set_text("S3") assert builder.validate() is None # type: ignore[func-returns-value] # validate() returns None; asserting it ran without raising d = builder.to_dict() @@ -1243,17 +1283,20 @@ def test_next_step_valid_not_last_in_multi_step(self) -> None: # Both gather steps remain present; step3 (the terminal) was # what made the next_step refs valid. assert names == ["step1", "step2", "step3"] - assert d["default"]["steps"][0]["gather_info"]["completion_action"] == "next_step" - assert d["default"]["steps"][1]["gather_info"]["completion_action"] == "next_step" + assert ( + d["default"]["steps"][0]["gather_info"]["completion_action"] == "next_step" + ) + assert ( + d["default"]["steps"][1]["gather_info"]["completion_action"] == "next_step" + ) def test_second_to_last_next_step_valid_last_next_step_invalid(self) -> None: builder = self._make_builder() ctx = builder.add_context("default") ctx.add_step("s1").set_text("S1") - ctx.add_step("s2") \ - .set_text("S2") \ - .set_gather_info(completion_action="next_step") \ - .add_gather_question("x", "Q?") + ctx.add_step("s2").set_text("S2").set_gather_info( + completion_action="next_step" + ).add_gather_question("x", "Q?") # s2 is the last step with pytest.raises(ValueError, match="last step"): builder.validate() @@ -1305,7 +1348,7 @@ class TestReservedToolNameValidation: """ContextBuilder.validate() must reject user tools that collide with reserved native tool names (next_step / change_context / gather_submit).""" - def _make_agent_with_tools(self, tool_names: List[str]) -> Mock: + def _make_agent_with_tools(self, tool_names: list[str]) -> Mock: """Build a mock agent that exposes a real dict of registered tools at agent._tool_registry._swaig_functions, matching the structure the production code reads from.""" @@ -1365,36 +1408,30 @@ def _make_builder(self) -> ContextBuilder: def test_next_step_on_last_step_error_lists_remediations(self) -> None: builder = self._make_builder() ctx = builder.add_context("default") - ctx.add_step("only") \ - .set_text("Last step") \ - .set_gather_info(completion_action="next_step") \ - .add_gather_question("x", "Q?") - try: + ctx.add_step("only").set_text("Last step").set_gather_info( + completion_action="next_step" + ).add_gather_question("x", "Q?") + with pytest.raises(ValueError) as excinfo: builder.validate() - assert False, "expected ValueError" - except ValueError as e: - msg = str(e) - # Suggestions an LLM can act on: - assert "add another step" in msg - assert "completion_action=None" in msg + msg = str(excinfo.value) + # Suggestions an LLM can act on: + assert "add another step" in msg + assert "completion_action=None" in msg def test_unknown_step_error_lists_available_steps(self) -> None: builder = self._make_builder() ctx = builder.add_context("default") ctx.add_step("alpha").set_text("A") - ctx.add_step("beta") \ - .set_text("B") \ - .set_gather_info(completion_action="gamma") \ - .add_gather_question("x", "Q?") - try: + ctx.add_step("beta").set_text("B").set_gather_info( + completion_action="gamma" + ).add_gather_question("x", "Q?") + with pytest.raises(ValueError) as excinfo: builder.validate() - assert False, "expected ValueError" - except ValueError as e: - msg = str(e) - assert "is not a step in this context" in msg - # Should enumerate the legal options - assert "alpha" in msg - assert "beta" in msg + msg = str(excinfo.value) + assert "is not a step in this context" in msg + # Should enumerate the legal options + assert "alpha" in msg + assert "beta" in msg class TestInitialStep: @@ -1473,6 +1510,7 @@ def test_reset_on_empty_builder(self) -> None: builder.reset() # should not raise assert len(builder._contexts) == 0 + class TestHistoryMode: """Step/context `history` visibility mode (keep | default | hide).""" @@ -1485,11 +1523,16 @@ def test_step_history_absent_by_default(self) -> None: assert "history" not in step.to_dict() def test_step_history_keep(self) -> None: - assert Step("s").set_text("t").set_history("keep").to_dict()["history"] == "keep" + assert ( + Step("s").set_text("t").set_history("keep").to_dict()["history"] == "keep" + ) def test_step_history_default_is_emitted_when_explicit(self) -> None: # Explicit "default" is still written out — it overrides a context default - assert Step("s").set_text("t").set_history("default").to_dict()["history"] == "default" + assert ( + Step("s").set_text("t").set_history("default").to_dict()["history"] + == "default" + ) def test_step_history_invalid_raises(self) -> None: with pytest.raises(ValueError, match="history must be one of"): diff --git a/tests/unit/core/test_data_map.py b/tests/unit/core/test_data_map.py index 7edc169b..f1d8ef1c 100644 --- a/tests/unit/core/test_data_map.py +++ b/tests/unit/core/test_data_map.py @@ -14,46 +14,48 @@ import pytest import json import re -from unittest.mock import Mock, patch, MagicMock -from typing import Pattern -from signalwire.core.data_map import DataMap, create_simple_api_tool, create_expression_tool +from signalwire.core.data_map import ( + DataMap, + create_simple_api_tool, + create_expression_tool, +) from signalwire.core.function_result import FunctionResult class TestDataMapBasic: """Test basic DataMap functionality""" - + def test_basic_creation(self) -> None: """Test creating a basic DataMap""" data_map = DataMap("test_function") - + assert data_map.function_name == "test_function" assert data_map._purpose == "" assert data_map._parameters == {} assert data_map._expressions == [] assert data_map._webhooks == [] - + def test_creation_with_purpose(self) -> None: """Test creating DataMap with purpose""" data_map = DataMap("test_function") data_map.purpose("Test function description") - + assert data_map.function_name == "test_function" assert data_map._purpose == "Test function description" - + def test_creation_with_description_alias(self) -> None: """Test using description as alias for purpose""" data_map = DataMap("test_function") data_map.description("Test function description") - + assert data_map._purpose == "Test function description" - + def test_parameter_addition(self) -> None: """Test adding parameters""" data_map = DataMap("test_function") data_map.parameter("location", "string", "City name", required=True) - + assert "location" in data_map._parameters param = data_map._parameters["location"] assert param["type"] == "string" @@ -64,39 +66,39 @@ def test_parameter_addition(self) -> None: class TestDataMapExpressions: """Test expression functionality""" - + def test_add_expression_with_string_pattern(self) -> None: """Test adding expression with string pattern""" data_map = DataMap("test_function") output = FunctionResult("Pattern matched") - + data_map.expression("${args.command}", r"start.*", output) - + assert len(data_map._expressions) == 1 expr = data_map._expressions[0] assert expr["string"] == "${args.command}" assert expr["pattern"] == r"start.*" assert expr["output"] == output.to_dict() - + def test_add_expression_with_compiled_pattern(self) -> None: """Test adding expression with compiled regex pattern""" data_map = DataMap("test_function") output = FunctionResult("Pattern matched") pattern = re.compile(r"stop.*") - + data_map.expression("${args.command}", pattern, output) - + expr = data_map._expressions[0] assert expr["pattern"] == r"stop.*" - + def test_add_expression_with_nomatch_output(self) -> None: """Test adding expression with nomatch output""" data_map = DataMap("test_function") match_output = FunctionResult("Matched") nomatch_output = FunctionResult("No match") - + data_map.expression("${args.command}", r"test.*", match_output, nomatch_output) - + expr = data_map._expressions[0] assert "nomatch-output" in expr assert expr["nomatch-output"] == nomatch_output.to_dict() @@ -104,114 +106,119 @@ def test_add_expression_with_nomatch_output(self) -> None: class TestDataMapWebhooks: """Test webhook functionality""" - + def test_add_basic_webhook(self) -> None: """Test adding basic webhook""" data_map = DataMap("test_function") - + data_map.webhook("GET", "https://api.example.com/data") - + assert len(data_map._webhooks) == 1 webhook = data_map._webhooks[0] assert webhook["method"] == "GET" assert webhook["url"] == "https://api.example.com/data" - + def test_add_webhook_with_headers(self) -> None: """Test adding webhook with headers""" data_map = DataMap("test_function") headers = {"Authorization": "Bearer token", "Content-Type": "application/json"} - + data_map.webhook("POST", "https://api.example.com/data", headers=headers) - + webhook = data_map._webhooks[0] assert webhook["headers"] == headers - + def test_add_webhook_with_options(self) -> None: """Test adding webhook with various options""" data_map = DataMap("test_function") - + data_map.webhook( - "POST", + "POST", "https://api.example.com/data", form_param="data", input_args_as_params=True, - require_args=["location"] + require_args=["location"], ) - + webhook = data_map._webhooks[0] assert webhook["form_param"] == "data" assert webhook["input_args_as_params"] is True assert webhook["require_args"] == ["location"] - - def test_webhook_body_and_params(self) -> None: - """Test adding body and params to webhook""" + + def test_webhook_params(self) -> None: + """params() writes the ``params`` webhook key — the one in the contract. + + Was ``test_webhook_body_and_params``, which called body() AND params() and + then asserted only ``len(_webhooks) == 1`` — it never checked either key, so + it passed for any shape. It now asserts the emitted key. + """ data_map = DataMap("test_function") - + data_map.webhook("POST", "https://api.example.com/data") - data_map.body({"query": "${location}", "format": "json"}) - data_map.params({"api_key": "12345"}) - - # Body and params should be stored for the last webhook - assert hasattr(data_map, '_webhooks') + data_map.params({"api_key": "12345", "query": "${location}"}) + assert len(data_map._webhooks) == 1 + wh = data_map._webhooks[0] + assert wh["params"] == {"api_key": "12345", "query": "${location}"} + assert "body" not in wh class TestDataMapOutput: """Test output functionality""" - + def test_set_output(self) -> None: """Test setting output""" data_map = DataMap("test_function") output = FunctionResult("API call successful: ${response.data}") - + # Must add webhook first data_map.webhook("GET", "https://api.example.com/data") data_map.output(output) - + # Output should be stored in the webhook assert data_map._webhooks[0]["output"] == output.to_dict() - + def test_set_fallback_output(self) -> None: """Test setting fallback output""" data_map = DataMap("test_function") fallback = FunctionResult("API unavailable") - + data_map.fallback_output(fallback) - + # Should be stored in the data map structure assert data_map._output == fallback.to_dict() class TestDataMapSerialization: """Test serialization functionality""" - + def test_to_swaig_function_basic(self) -> None: """Test basic to_swaig_function conversion""" data_map = DataMap("test_function") data_map.purpose("Test function") data_map.parameter("location", "string", "City name", required=True) - + swaig_func = data_map.to_swaig_function() - + assert swaig_func["function"] == "test_function" assert swaig_func["description"] == "Test function" assert "parameters" in swaig_func assert "properties" in swaig_func["parameters"] assert "location" in swaig_func["parameters"]["properties"] - + def test_to_swaig_function_with_expressions(self) -> None: """Test to_swaig_function with expressions""" data_map = DataMap("test_function") data_map.purpose("Test function") output = FunctionResult("Expression result") data_map.expression("${args.command}", r"test.*", output) - + swaig_func = data_map.to_swaig_function() - + assert "data_map" in swaig_func assert "expressions" in swaig_func["data_map"] assert len(swaig_func["data_map"]["expressions"]) == 1 - + def test_to_swaig_function_with_webhooks(self) -> None: """Test to_swaig_function with webhooks""" data_map = DataMap("test_function") @@ -219,9 +226,9 @@ def test_to_swaig_function_with_webhooks(self) -> None: data_map.webhook("GET", "https://api.example.com/data") output = FunctionResult("Webhook result: ${response.data}") data_map.output(output) - + swaig_func = data_map.to_swaig_function() - + assert "data_map" in swaig_func assert "webhooks" in swaig_func["data_map"] assert len(swaig_func["data_map"]["webhooks"]) == 1 @@ -229,217 +236,316 @@ def test_to_swaig_function_with_webhooks(self) -> None: class TestDataMapChaining: """Test method chaining functionality""" - + def test_method_chaining(self) -> None: """Test that methods return self for chaining""" output = FunctionResult("Chained result") - - data_map = (DataMap("test_function") - .purpose("Test chaining") - .parameter("param1", "string", "Parameter 1") - .webhook("GET", "https://api.example.com/data") - .output(output)) - + + data_map = ( + DataMap("test_function") + .purpose("Test chaining") + .parameter("param1", "string", "Parameter 1") + .webhook("GET", "https://api.example.com/data") + .output(output) + ) + assert data_map.function_name == "test_function" assert data_map._purpose == "Test chaining" assert "param1" in data_map._parameters assert len(data_map._webhooks) == 1 - + def test_complex_chaining(self) -> None: """Test complex method chaining""" result = FunctionResult() result.say("Complex result") - - data_map = (DataMap("complex_function") - .purpose("Complex test") - .parameter("input", "string", "Input data", required=True) - .webhook("POST", "https://api.example.com/process") - .body({"data": "${input}"}) - .output(result)) - + + data_map = ( + DataMap("complex_function") + .purpose("Complex test") + .parameter("input", "string", "Input data", required=True) + .webhook("POST", "https://api.example.com/process") + .params({"data": "${input}"}) + .output(result) + ) + assert data_map._purpose == "Complex test" assert "input" in data_map._parameters class TestDataMapFactoryFunctions: """Test factory functions""" - + def test_create_simple_api_tool(self) -> None: """Test create_simple_api_tool factory""" data_map = create_simple_api_tool( name="weather_tool", url="https://api.weather.com/current?location=${location}", - response_template="Weather: ${response.condition}, ${response.temp}°F" + response_template="Weather: ${response.condition}, ${response.temp}°F", ) - + assert isinstance(data_map, DataMap) assert data_map.function_name == "weather_tool" - + def test_create_simple_api_tool_with_parameters(self) -> None: """Test create_simple_api_tool with parameters""" - parameters = { - "location": {"type": "string", "description": "City name"} - } - + parameters = {"location": {"type": "string", "description": "City name"}} + data_map = create_simple_api_tool( name="weather_tool", url="https://api.weather.com/current", response_template="Weather data", - parameters=parameters + parameters=parameters, ) - + assert isinstance(data_map, DataMap) - + + def test_create_simple_api_tool_rejects_body(self) -> None: + """`create_simple_api_tool` has no `body` parameter. + + `body` is not a valid webhook key: porting-sdk/schema.json `$defs/Webhook` + declares exactly ten properties (error_keys, expressions, foreach, headers, + input_args_as_params, method, output, params, require_args, url) under + `unevaluatedProperties: {"not": {}}`, and neither engine reader + (mod_openai/actions.c parse_webhook, mod_openai/bedrock.c + bedrock_parse_webhook) looks up "body". Accepting the argument and + discarding it into an unread key silently lost the caller's data. + """ + with pytest.raises(TypeError): + create_simple_api_tool( # type: ignore[call-arg] + name="poster", + url="https://api.example.com/search", + response_template="Found: ${response.title}", + method="POST", + body={"query": "${args.q}"}, + ) + + def test_create_simple_api_tool_emits_no_body_key(self) -> None: + """The EMITTED webhook payload carries no `body` key.""" + data_map = create_simple_api_tool( + name="poster", + url="https://api.example.com/search", + response_template="Found: ${response.title}", + parameters={"q": {"type": "string", "description": "Query"}}, + method="POST", + headers={"Authorization": "Bearer TOKEN"}, + error_keys=["error"], + ) + + emitted = data_map.to_swaig_function() + webhooks = emitted["data_map"]["webhooks"] + assert len(webhooks) == 1 + assert "body" not in webhooks[0], f"webhook carries a body key: {webhooks[0]!r}" + + allowed = { + "error_keys", + "expressions", + "foreach", + "headers", + "input_args_as_params", + "method", + "output", + "params", + "require_args", + "url", + } + assert set(webhooks[0]) <= allowed, ( + f"webhook has keys outside schema.json $defs/Webhook: " + f"{sorted(set(webhooks[0]) - allowed)}" + ) + def test_create_expression_tool(self) -> None: """Test create_expression_tool factory""" + # NOTE: create_expression_tool takes dict[test_value -> (pattern, result)], + # so the test_value is the KEY -- two patterns cannot share one test_value + # (this literal previously repeated "${args.command}" twice and the second + # entry silently clobbered the first, so only "stop" was ever exercised). patterns = { "${args.command}": ("start", FunctionResult().add_action("start", True)), - "${args.command}": ("stop", FunctionResult().add_action("stop", True)) + "${args.mode}": ("stop", FunctionResult().add_action("stop", True)), } - + data_map = create_expression_tool("control_tool", patterns) - + assert isinstance(data_map, DataMap) assert data_map.function_name == "control_tool" - + # Both entries must survive -- one expression per pattern, in order. + expressions = data_map.to_swaig_function()["data_map"]["expressions"] + assert [(e["string"], e["pattern"]) for e in expressions] == [ + ("${args.command}", "start"), + ("${args.mode}", "stop"), + ] + def test_create_expression_tool_with_parameters(self) -> None: """Test create_expression_tool with parameters""" - patterns = { - "${args.input}": ("test", FunctionResult("Test result")) - } - parameters = { - "input": {"type": "string", "description": "Input text"} - } - + patterns = {"${args.input}": ("test", FunctionResult("Test result"))} + parameters = {"input": {"type": "string", "description": "Input text"}} + data_map = create_expression_tool("test_tool", patterns, parameters) - + assert isinstance(data_map, DataMap) class TestDataMapErrorHandling: """Test error handling and edge cases""" - + def test_empty_function_name(self) -> None: """Test creating DataMap with empty function name""" # Should not raise error, just store empty string data_map = DataMap("") assert data_map.function_name == "" - + def test_none_function_name(self) -> None: """Test creating DataMap with None function name""" # Should not raise error, just store None data_map = DataMap(None) # type: ignore[arg-type] # intentional invalid input for validation test assert data_map.function_name is None - + def test_invalid_parameter_type(self) -> None: """Test adding parameter with invalid type""" data_map = DataMap("test_function") - + # Should not validate type, just store it data_map.parameter("test_param", "invalid_type", "Test parameter") - + param = data_map._parameters["test_param"] assert param["type"] == "invalid_type" - + def test_duplicate_parameter_names(self) -> None: """Test adding parameters with duplicate names""" data_map = DataMap("test_function") - + data_map.parameter("param1", "string", "First description") data_map.parameter("param1", "number", "Second description") - + # Should overwrite the first parameter param = data_map._parameters["param1"] assert param["type"] == "number" assert param["description"] == "Second description" - + def test_output_without_webhook(self) -> None: """Test setting output without webhook raises error""" data_map = DataMap("test_function") output = FunctionResult("Test output") - + with pytest.raises(ValueError, match="Must add webhook before setting output"): data_map.output(output) class TestDataMapTemplateVariables: """Test template variable handling""" - + def test_env_variables_in_webhooks(self) -> None: """Test environment variables in webhook URLs""" data_map = DataMap("test_function") - + data_map.webhook("GET", "https://api.example.com/data?key=${ENV.API_KEY}") - + webhook = data_map._webhooks[0] assert "${ENV.API_KEY}" in webhook["url"] - + def test_args_variables_in_body(self) -> None: """Test argument variables in request body""" data_map = DataMap("test_function") - + data_map.webhook("POST", "https://api.example.com/data") - data_map.body({"query": "${args.search_term}", "limit": 10}) - + data_map.params({"query": "${args.search_term}", "limit": 10}) + # Body should be stored for processing assert len(data_map._webhooks) == 1 - + def test_response_variables_in_output(self) -> None: """Test response variables in output templates""" data_map = DataMap("test_function") output = FunctionResult("Result: ${response.data.title}") - + data_map.webhook("GET", "https://api.example.com/data") data_map.output(output) - + assert data_map._webhooks[0]["output"] == output.to_dict() class TestDataMapIntegration: """Test integration with other components""" - + def test_agent_integration(self) -> None: """Test DataMap integration with agent""" data_map = DataMap("test_tool") data_map.purpose("Test integration") data_map.parameter("input", "string", "Input data") - + swaig_func = data_map.to_swaig_function() - + # Should be compatible with agent.define_tool assert "function" in swaig_func assert "description" in swaig_func assert "parameters" in swaig_func - + def test_swaig_function_compatibility(self) -> None: """Test compatibility with FunctionResult""" data_map = DataMap("test_function") result = FunctionResult("Test response") result.add_action("test_action", {"key": "value"}) - + data_map.webhook("GET", "https://api.example.com/data") data_map.output(result) - + # Should store the result properly assert data_map._webhooks[0]["output"] == result.to_dict() - + def test_json_serialization(self) -> None: """Test JSON serialization of complete DataMap""" - import json - + data_map = DataMap("serialization_test") data_map.purpose("Test serialization") data_map.parameter("input", "string", "Test input") result = FunctionResult("Serialized result") data_map.webhook("GET", "https://api.example.com/data") data_map.output(result) - + swaig_func = data_map.to_swaig_function() - + # Should be JSON serializable json_str = json.dumps(swaig_func) assert isinstance(json_str, str) - + # Should be deserializable parsed = json.loads(json_str) - assert parsed["function"] == "serialization_test" \ No newline at end of file + assert parsed["function"] == "serialization_test" + + +class TestBodyBuilderRemoved: + """``DataMap.body()`` is GONE — the key it wrote is invalid, not merely ignored. + + Owner-ruled 2026-07-29, extending the f171ce3 ruling ("if the server doesn't + read them, remove them") from the ``create_simple_api_tool`` PARAMETER to the + public BUILDER METHOD. The same three sources condemn both: + + * ``porting-sdk/schema.json`` ``$defs/Webhook`` declares exactly ten properties + under ``unevaluatedProperties: {"not": {}}`` — ``body`` is not among them, so + emitting it is a SCHEMA VIOLATION. + * ``mod_openai/actions.c:735-739`` and ``bedrock.c:4920-4926`` read url, method, + form_param, ``params`` and ``headers`` and nothing else; ``grep -n '"body"'`` + across both returns ZERO matches. + * So the method's only possible effect was producing an invalid document while + silently discarding the caller's payload. + + ``params()`` is the correct method for POST/PUT request data — it writes the + ``params`` key, which IS in the contract and IS read. + """ + + def test_body_method_is_gone(self) -> None: + from signalwire.core.data_map import DataMap + + assert not hasattr(DataMap, "body"), ( + "DataMap.body() must be removed — it writes a schema-forbidden key " + "that no engine reader consumes; use params() instead" + ) + + def test_params_still_writes_the_contract_key(self) -> None: + """The replacement must keep working — this is the positive control.""" + from signalwire.core.data_map import DataMap + + dm = DataMap("t").webhook("POST", "https://x.test").params({"q": "${query}"}) + wh = dm.to_swaig_function()["data_map"]["webhooks"][0] + assert wh["params"] == {"q": "${query}"} + assert "body" not in wh diff --git a/tests/unit/core/test_function_result.py b/tests/unit/core/test_function_result.py index 0803de39..3cd66e88 100644 --- a/tests/unit/core/test_function_result.py +++ b/tests/unit/core/test_function_result.py @@ -13,104 +13,103 @@ import pytest import json -from typing import Any, Dict, List -from unittest.mock import Mock, patch +from typing import Any from signalwire.core.function_result import FunctionResult class TestFunctionResultBasic: """Test basic FunctionResult functionality""" - + def test_basic_response_creation(self) -> None: """Test creating a basic response""" result = FunctionResult(response="Hello, world!") - + assert result.response == "Hello, world!" assert result.action == [] assert result.post_process is False - + # Test to_dict conversion result_dict = result.to_dict() assert result_dict["response"] == "Hello, world!" - + def test_response_with_action(self) -> None: """Test creating response with action""" result = FunctionResult(response="Processing request") result.add_action("transfer", "+15551234567") - + assert result.response == "Processing request" assert len(result.action) == 1 assert result.action[0] == {"transfer": "+15551234567"} - + result_dict = result.to_dict() assert result_dict["response"] == "Processing request" assert result_dict["action"] == [{"transfer": "+15551234567"}] - + def test_empty_response(self) -> None: """Test creating empty response""" result = FunctionResult() - + assert result.response == "" result_dict = result.to_dict() # Empty response gets default message assert result_dict["response"] == "Action completed." - + def test_post_process_setting(self) -> None: """Test setting post_process flag""" result = FunctionResult(post_process=True) result.add_action("test", "value") # Need action for post_process to appear - + assert result.post_process is True - + result_dict = result.to_dict() assert result_dict["post_process"] is True class TestFunctionResultActions: """Test action-related methods""" - + def test_add_action(self) -> None: """Test adding a single action""" result = FunctionResult() result.add_action("play", {"url": "https://example.com/audio.mp3"}) - + assert len(result.action) == 1 assert result.action[0] == {"play": {"url": "https://example.com/audio.mp3"}} - + def test_add_multiple_actions(self) -> None: """Test adding multiple actions""" result = FunctionResult() - actions: List[Dict[str, Any]] = [ + actions: list[dict[str, Any]] = [ {"play": {"url": "https://example.com/audio.mp3"}}, - {"transfer": "+15551234567"} + {"transfer": "+15551234567"}, ] result.add_actions(actions) - + assert len(result.action) == 2 assert result.action == actions - + def test_connect_action(self) -> None: """Test the connect action helper""" result = FunctionResult() result.connect("+15551234567", final=True) - + assert len(result.action) == 1 action = result.action[0] assert "SWML" in action assert action["transfer"] == "true" - + swml = action["SWML"] assert swml["sections"]["main"][0]["connect"]["to"] == "+15551234567" - + def test_connect_with_from_addr(self) -> None: """Test connect action with from address""" result = FunctionResult() result.connect("+15551234567", final=False, from_addr="+15559876543") - + action = result.action[0] assert action["transfer"] == "false" - + connect_params = action["SWML"]["sections"]["main"][0]["connect"] assert connect_params["to"] == "+15551234567" assert connect_params["from"] == "+15559876543" @@ -118,44 +117,44 @@ def test_connect_with_from_addr(self) -> None: class TestFunctionResultSWMLMethods: """Test SWML-specific methods""" - + def test_say_method(self) -> None: """Test the say method""" result = FunctionResult() result.say("Hello there") - + assert len(result.action) == 1 assert result.action[0] == {"say": "Hello there"} - + def test_hangup_method(self) -> None: """Test the hangup method""" result = FunctionResult() result.hangup() - + assert len(result.action) == 1 assert result.action[0] == {"hangup": True} - + def test_hold_method(self) -> None: """Test the hold method""" result = FunctionResult() result.hold(timeout=60) - + assert len(result.action) == 1 assert result.action[0] == {"hold": 60} - + def test_stop_method(self) -> None: """Test the stop method""" result = FunctionResult() result.stop() - + assert len(result.action) == 1 assert result.action[0] == {"stop": True} - + def test_wait_for_user_method(self) -> None: """Test the wait_for_user method""" result = FunctionResult() result.wait_for_user(enabled=True, timeout=30) - + assert len(result.action) == 1 action = result.action[0] assert "wait_for_user" in action @@ -165,29 +164,32 @@ def test_wait_for_user_method(self) -> None: class TestFunctionResultChaining: """Test method chaining functionality""" - + def test_method_chaining(self) -> None: """Test that methods return self for chaining""" result = FunctionResult("Initial response") - - chained = (result - .set_response("Updated response") - .set_post_process(True) - .add_action("play", {"url": "test.mp3"})) - + + chained = ( + result.set_response("Updated response") + .set_post_process(True) + .add_action("play", {"url": "test.mp3"}) + ) + # Should return the same instance assert chained is result assert result.response == "Updated response" assert result.post_process is True assert len(result.action) == 1 - + def test_complex_chaining(self) -> None: """Test complex method chaining""" - result = (FunctionResult("Welcome") - .say("Please hold") - .add_action("play", {"url": "music.mp3"}) - .set_post_process(True)) - + result = ( + FunctionResult("Welcome") + .say("Please hold") + .add_action("play", {"url": "music.mp3"}) + .set_post_process(True) + ) + assert result.response == "Welcome" assert result.post_process is True assert len(result.action) == 2 @@ -195,44 +197,40 @@ def test_complex_chaining(self) -> None: class TestFunctionResultAdvanced: """Test advanced functionality""" - + def test_update_global_data(self) -> None: """Test updating global data""" result = FunctionResult() result.update_global_data({"user_id": "123", "session": "abc"}) - + assert len(result.action) == 1 action = result.action[0] assert "set_global_data" in action # add_action("set_global_data", data) produces {"set_global_data": data} assert action["set_global_data"]["user_id"] == "123" assert action["set_global_data"]["session"] == "abc" - + def test_execute_swml(self) -> None: """Test executing custom SWML""" - swml_content = { - "sections": { - "main": [{"play": {"url": "test.mp3"}}] - } - } - + swml_content = {"sections": {"main": [{"play": {"url": "test.mp3"}}]}} + result = FunctionResult() result.execute_swml(swml_content) - + assert len(result.action) == 1 action = result.action[0] assert "SWML" in action assert action["SWML"] == swml_content - + def test_switch_context(self) -> None: """Test switching context""" result = FunctionResult() result.switch_context( system_prompt="New system prompt", user_prompt="New user prompt", - consolidate=True + consolidate=True, ) - + assert len(result.action) == 1 action = result.action[0] assert "context_switch" in action @@ -245,53 +243,50 @@ def test_switch_context(self) -> None: class TestFunctionResultSerialization: """Test serialization and deserialization""" - + def test_to_dict_basic(self) -> None: """Test basic to_dict conversion""" result = FunctionResult(response="Test response") result_dict = result.to_dict() - + assert isinstance(result_dict, dict) assert "response" in result_dict assert result_dict["response"] == "Test response" - + def test_to_dict_with_actions(self) -> None: """Test to_dict with actions""" result = FunctionResult("Test") result.add_action("play", {"url": "test.mp3"}) - + result_dict = result.to_dict() - + assert "action" in result_dict assert isinstance(result_dict["action"], list) assert len(result_dict["action"]) == 1 - + def test_to_dict_with_all_fields(self) -> None: """Test to_dict with all possible fields""" - result = FunctionResult( - response="Complete response", - post_process=True - ) + result = FunctionResult(response="Complete response", post_process=True) result.add_action("transfer", "+15551234567") - + result_dict = result.to_dict() - + assert result_dict["response"] == "Complete response" assert result_dict["post_process"] is True assert "action" in result_dict assert len(result_dict["action"]) == 1 - + def test_json_serialization(self) -> None: """Test JSON serialization""" result = FunctionResult("Hello JSON") result.say("Additional message") - + result_dict = result.to_dict() - + # Should be JSON serializable json_str = json.dumps(result_dict) assert isinstance(json_str, str) - + # Should be deserializable parsed = json.loads(json_str) assert parsed["response"] == "Hello JSON" @@ -299,33 +294,33 @@ def test_json_serialization(self) -> None: class TestFunctionResultErrorHandling: """Test error handling and edge cases""" - + def test_none_response(self) -> None: """Test handling of None response""" result = FunctionResult(response=None) # Should convert to empty string assert result.response == "" - + def test_empty_actions(self) -> None: """Test handling when no actions are present""" result = FunctionResult(response="No actions") result_dict = result.to_dict() - + # Should have response but no action key when no actions assert "response" in result_dict assert "action" not in result_dict or result_dict.get("action") == [] - + def test_invalid_action_data(self) -> None: """Test adding action with various data types""" result = FunctionResult() - + # Should handle different data types result.add_action("test_string", "string_value") result.add_action("test_number", 42) result.add_action("test_boolean", True) result.add_action("test_object", {"key": "value"}) result.add_action("test_array", [1, 2, 3]) - + assert len(result.action) == 5 assert result.action[0]["test_string"] == "string_value" assert result.action[1]["test_number"] == 42 @@ -336,71 +331,68 @@ def test_invalid_action_data(self) -> None: class TestFunctionResultFactoryMethods: """Test factory-like usage patterns""" - + def test_success_response(self) -> None: """Test creating success response""" result = FunctionResult("Operation successful") - + assert result.response == "Operation successful" result_dict = result.to_dict() assert result_dict["response"] == "Operation successful" - + def test_error_response(self) -> None: """Test creating error response""" result = FunctionResult("Error occurred") - + assert result.response == "Error occurred" - + def test_transfer_response(self) -> None: """Test creating transfer response""" result = FunctionResult("Transferring you now") result.connect("+15551234567") - + result_dict = result.to_dict() assert "action" in result_dict assert len(result_dict["action"]) == 1 - + def test_information_response(self) -> None: """Test creating informational response""" result = FunctionResult("Here is the information you requested") - + assert "information" in result.response.lower() class TestFunctionResultIntegration: """Test integration with other components""" - + def test_agent_integration(self) -> None: """Test integration with agent tools""" # This would typically be tested in integration tests # but we can test the interface here - + def mock_tool_handler() -> FunctionResult: return FunctionResult("Tool executed successfully") - + result = mock_tool_handler() assert isinstance(result, FunctionResult) assert result.response == "Tool executed successfully" - + def test_datamap_integration(self) -> None: """Test integration with DataMap responses""" result = FunctionResult("DataMap response") result_dict = result.to_dict() - + # Should be compatible with DataMap expected format assert "response" in result_dict assert isinstance(result_dict, dict) - + def test_webhook_response_format(self) -> None: """Test webhook response format compatibility""" - result = FunctionResult( - response="Webhook processed", - post_process=False - ) + result = FunctionResult(response="Webhook processed", post_process=False) result.add_action("continue", True) - + result_dict = result.to_dict() - + # Should have the format expected by SignalWire assert "response" in result_dict assert "action" in result_dict @@ -455,7 +447,11 @@ class TestSwmlUserEvent: def test_swml_user_event_basic(self) -> None: """Test sending a user event with event data dict""" - event_data = {"type": "cards_dealt", "player_hand": ["Ace", "King"], "score": 21} + event_data = { + "type": "cards_dealt", + "player_hand": ["Ace", "King"], + "score": 21, + } result = FunctionResult("Blackjack!").swml_user_event(event_data) assert len(result.action) == 1 @@ -559,7 +555,9 @@ def to_dict(self) -> dict[str, Any]: def test_execute_swml_invalid_type_raises_type_error(self) -> None: """Test execute_swml with invalid type raises TypeError""" - with pytest.raises(TypeError, match="swml_content must be string, dict, or SWML object"): + with pytest.raises( + TypeError, match="swml_content must be string, dict, or SWML object" + ): FunctionResult().execute_swml(12345) def test_execute_swml_invalid_type_list(self) -> None: @@ -660,7 +658,9 @@ def test_wait_for_user_no_args(self) -> None: def test_wait_for_user_answer_first_takes_priority(self) -> None: """Test that answer_first takes priority over other args""" - result = FunctionResult().wait_for_user(enabled=True, timeout=30, answer_first=True) + result = FunctionResult().wait_for_user( + enabled=True, timeout=30, answer_first=True + ) assert result.action[0] == {"wait_for_user": "answer_first"} def test_wait_for_user_timeout_takes_priority_over_enabled(self) -> None: @@ -726,7 +726,9 @@ def test_remove_global_data_single_string(self) -> None: def test_remove_global_data_list_of_keys(self) -> None: """Test remove_global_data with a list of keys""" result = FunctionResult().remove_global_data(["user_id", "session", "token"]) - assert result.action[0] == {"unset_global_data": ["user_id", "session", "token"]} + assert result.action[0] == { + "unset_global_data": ["user_id", "session", "token"] + } def test_remove_global_data_chaining(self) -> None: """Test remove_global_data returns self for chaining""" @@ -786,7 +788,9 @@ def test_pay_default_params(self) -> None: assert "ai_response" in main_section[0]["set"] # Second item is pay pay_params = main_section[1]["pay"] - assert pay_params["payment_connector_url"] == "https://pay.example.com/connector" + assert ( + pay_params["payment_connector_url"] == "https://pay.example.com/connector" + ) assert pay_params["input"] == "dtmf" assert pay_params["payment_method"] == "credit-card" assert pay_params["timeout"] == "5" @@ -819,7 +823,7 @@ def test_pay_all_custom_params(self) -> None: voice="man", description="Monthly subscription", valid_card_types="visa amex", - ai_response="Payment processed." + ai_response="Payment processed.", ) pay_params = result.action[0]["SWML"]["sections"]["main"][1]["pay"] @@ -838,17 +842,24 @@ def test_pay_all_custom_params(self) -> None: assert pay_params["description"] == "Monthly subscription" assert pay_params["valid_card_types"] == "visa amex" - ai_response = result.action[0]["SWML"]["sections"]["main"][0]["set"]["ai_response"] + ai_response = result.action[0]["SWML"]["sections"]["main"][0]["set"][ + "ai_response" + ] assert ai_response == "Payment processed." def test_pay_with_prompts_and_parameters(self) -> None: """Test pay with custom prompts and parameters""" - prompts = [{"for": "payment-card-number", "actions": [{"type": "Say", "phrase": "Enter card"}]}] + prompts = [ + { + "for": "payment-card-number", + "actions": [{"type": "Say", "phrase": "Enter card"}], + } + ] parameters = [{"name": "store_id", "value": "123"}] result = FunctionResult().pay( payment_connector_url="https://pay.example.com", prompts=prompts, - parameters=parameters + parameters=parameters, ) pay_params = result.action[0]["SWML"]["sections"]["main"][1]["pay"] @@ -858,8 +869,7 @@ def test_pay_with_prompts_and_parameters(self) -> None: def test_pay_postal_code_boolean_false(self) -> None: """Test pay with postal_code as boolean False""" result = FunctionResult().pay( - payment_connector_url="https://pay.example.com", - postal_code=False + payment_connector_url="https://pay.example.com", postal_code=False ) pay_params = result.action[0]["SWML"]["sections"]["main"][1]["pay"] assert pay_params["postal_code"] == "false" @@ -903,7 +913,7 @@ def test_join_conference_complex_params(self) -> None: recording_status_callback="https://example.com/rec-callback", recording_status_callback_method="GET", recording_status_callback_event="in-progress", - result={"key": "value"} + result={"key": "value"}, ) swml = result.action[0]["SWML"] @@ -923,7 +933,10 @@ def test_join_conference_complex_params(self) -> None: assert join_params["status_callback_event"] == "start end" assert join_params["status_callback"] == "https://example.com/callback" assert join_params["status_callback_method"] == "GET" - assert join_params["recording_status_callback"] == "https://example.com/rec-callback" + assert ( + join_params["recording_status_callback"] + == "https://example.com/rec-callback" + ) assert join_params["recording_status_callback_method"] == "GET" assert join_params["recording_status_callback_event"] == "in-progress" assert join_params["result"] == {"key": "value"} @@ -935,17 +948,23 @@ def test_join_conference_invalid_beep(self) -> None: def test_join_conference_max_participants_too_high(self) -> None: """Test join_conference with max_participants > 250 raises ValueError""" - with pytest.raises(ValueError, match="max_participants must be a positive integer <= 250"): + with pytest.raises( + ValueError, match="max_participants must be a positive integer <= 250" + ): FunctionResult().join_conference("conf", max_participants=300) def test_join_conference_max_participants_zero(self) -> None: """Test join_conference with max_participants=0 raises ValueError""" - with pytest.raises(ValueError, match="max_participants must be a positive integer <= 250"): + with pytest.raises( + ValueError, match="max_participants must be a positive integer <= 250" + ): FunctionResult().join_conference("conf", max_participants=0) def test_join_conference_max_participants_negative(self) -> None: """Test join_conference with negative max_participants raises ValueError""" - with pytest.raises(ValueError, match="max_participants must be a positive integer <= 250"): + with pytest.raises( + ValueError, match="max_participants must be a positive integer <= 250" + ): FunctionResult().join_conference("conf", max_participants=-5) def test_join_conference_invalid_record(self) -> None: @@ -975,8 +994,12 @@ def test_join_conference_invalid_status_callback_method(self) -> None: def test_join_conference_invalid_recording_status_callback_method(self) -> None: """Test join_conference with invalid recording_status_callback_method raises ValueError""" - with pytest.raises(ValueError, match="recording_status_callback_method must be one of"): - FunctionResult().join_conference("conf", recording_status_callback_method="DELETE") + with pytest.raises( + ValueError, match="recording_status_callback_method must be one of" + ): + FunctionResult().join_conference( + "conf", recording_status_callback_method="DELETE" + ) def test_join_conference_chaining(self) -> None: """Test join_conference returns self for chaining""" @@ -1008,7 +1031,7 @@ def test_tap_custom_params(self) -> None: direction="speak", codec="PCMA", rtp_ptime=30, - status_url="https://example.com/status" + status_url="https://example.com/status", ) tap_params = result.action[0]["SWML"]["sections"]["main"][0]["tap"] @@ -1107,7 +1130,7 @@ def test_record_call_custom_params(self) -> None: initial_timeout=10.0, end_silence_timeout=5.0, max_length=600.0, - status_url="https://example.com/rec-status" + status_url="https://example.com/rec-status", ) rec_params = result.action[0]["SWML"]["sections"]["main"][0]["record_call"] @@ -1137,7 +1160,9 @@ def test_record_call_format_mp4(self) -> None: def test_record_call_invalid_direction(self) -> None: """Test record_call with invalid direction raises ValueError""" - with pytest.raises(ValueError, match="direction must be 'speak', 'listen', or 'both'"): + with pytest.raises( + ValueError, match="direction must be 'speak', 'listen', or 'both'" + ): FunctionResult().record_call(direction="left") # type: ignore[arg-type] # intentional invalid input for validation test def test_record_call_direction_listen(self) -> None: @@ -1162,6 +1187,7 @@ def test_validated_closed_sets_declared_as_literal(self) -> None: options pattern). This test guards BOTH decisions against regression.""" import inspect import typing + literal_cases = [ (FunctionResult.record_call, "format", ("wav", "mp3", "mp4")), (FunctionResult.record_call, "direction", ("speak", "listen", "both")), @@ -1170,19 +1196,30 @@ def test_validated_closed_sets_declared_as_literal(self) -> None: ] for fn, param, expected in literal_cases: ann = inspect.signature(fn).parameters[param].annotation # type: ignore[arg-type] # method object from heterogeneous tuple introspection - assert typing.get_origin(ann) is typing.Literal, \ + assert typing.get_origin(ann) is typing.Literal, ( f"{fn.__name__}.{param} should be Literal, got {ann!r}" - assert typing.get_args(ann) == expected, \ + ) + assert typing.get_args(ann) == expected, ( f"{fn.__name__}.{param} literal={typing.get_args(ann)} != {expected}" + ) # Conference sets are deliberately bare str (validated at runtime, not # type-enforced). Re-adding Literal here means also typing them across # every full-param port, or the audit drifts — see Literal wave-1. - for param in ("beep", "record", "trim", "status_callback_method", - "recording_status_callback_method"): - ann = inspect.signature( - FunctionResult.join_conference).parameters[param].annotation - assert ann is str, \ + for param in ( + "beep", + "record", + "trim", + "status_callback_method", + "recording_status_callback_method", + ): + ann = ( + inspect.signature(FunctionResult.join_conference) + .parameters[param] + .annotation + ) + assert ann is str, ( f"join_conference.{param} should stay bare str, got {ann!r}" + ) class TestStopRecordCall: @@ -1217,9 +1254,7 @@ class TestSendSms: def test_send_sms_with_body(self) -> None: """Test send_sms with body text""" result = FunctionResult().send_sms( - to_number="+15551234567", - from_number="+15559876543", - body="Hello from AI" + to_number="+15551234567", from_number="+15559876543", body="Hello from AI" ) swml = result.action[0]["SWML"] @@ -1234,7 +1269,7 @@ def test_send_sms_with_media(self) -> None: result = FunctionResult().send_sms( to_number="+15551234567", from_number="+15559876543", - media=["https://example.com/image.png"] + media=["https://example.com/image.png"], ) sms_params = result.action[0]["SWML"]["sections"]["main"][0]["send_sms"] @@ -1247,7 +1282,7 @@ def test_send_sms_with_body_and_media(self) -> None: to_number="+15551234567", from_number="+15559876543", body="Check this out", - media=["https://example.com/image.png"] + media=["https://example.com/image.png"], ) sms_params = result.action[0]["SWML"]["sections"]["main"][0]["send_sms"] @@ -1258,8 +1293,7 @@ def test_send_sms_missing_both_raises_value_error(self) -> None: """Test send_sms with neither body nor media raises ValueError""" with pytest.raises(ValueError, match="Either body or media must be provided"): FunctionResult().send_sms( - to_number="+15551234567", - from_number="+15559876543" + to_number="+15551234567", from_number="+15559876543" ) def test_send_sms_with_tags_and_region(self) -> None: @@ -1269,7 +1303,7 @@ def test_send_sms_with_tags_and_region(self) -> None: from_number="+15559876543", body="Tagged message", tags=["support", "urgent"], - region="us-east" + region="us-east", ) sms_params = result.action[0]["SWML"]["sections"]["main"][0]["send_sms"] @@ -1339,7 +1373,7 @@ def test_execute_rpc_with_all_params(self) -> None: method="ai_message", params={"role": "system", "message_text": "Hello"}, call_id="call-123", - node_id="node-456" + node_id="node-456", ) rpc_params = result.action[0]["SWML"]["sections"]["main"][0]["execute_rpc"] @@ -1371,7 +1405,7 @@ def test_rpc_dial_basic(self) -> None: result = FunctionResult().rpc_dial( to_number="+15551234567", from_number="+15559876543", - dest_swml="https://example.com/call-agent" + dest_swml="https://example.com/call-agent", ) rpc_params = result.action[0]["SWML"]["sections"]["main"][0]["execute_rpc"] @@ -1388,10 +1422,12 @@ def test_rpc_dial_custom_device_type(self) -> None: to_number="+15551234567", from_number="+15559876543", dest_swml="https://example.com/swml", - device_type="sip" + device_type="sip", ) - params = result.action[0]["SWML"]["sections"]["main"][0]["execute_rpc"]["params"] + params = result.action[0]["SWML"]["sections"]["main"][0]["execute_rpc"][ + "params" + ] assert params["devices"]["type"] == "sip" def test_rpc_dial_chaining(self) -> None: @@ -1407,8 +1443,7 @@ class TestRpcAiMessage: def test_rpc_ai_message_basic(self) -> None: """Test rpc_ai_message basic usage""" result = FunctionResult().rpc_ai_message( - call_id="call-abc", - message_text="Please take a message." + call_id="call-abc", message_text="Please take a message." ) rpc_params = result.action[0]["SWML"]["sections"]["main"][0]["execute_rpc"] @@ -1420,12 +1455,12 @@ def test_rpc_ai_message_basic(self) -> None: def test_rpc_ai_message_custom_role(self) -> None: """Test rpc_ai_message with custom role""" result = FunctionResult().rpc_ai_message( - call_id="call-xyz", - message_text="User said hello", - role="user" + call_id="call-xyz", message_text="User said hello", role="user" ) - params = result.action[0]["SWML"]["sections"]["main"][0]["execute_rpc"]["params"] + params = result.action[0]["SWML"]["sections"]["main"][0]["execute_rpc"][ + "params" + ] assert params["role"] == "user" def test_rpc_ai_message_chaining(self) -> None: @@ -1490,8 +1525,7 @@ def test_create_payment_prompt_with_both(self) -> None: """Test create_payment_prompt with both card_type and error_type""" actions = [{"type": "Say", "phrase": "Try again"}] prompt = FunctionResult.create_payment_prompt( - "payment-card-number", actions, - card_type="visa", error_type="timeout" + "payment-card-number", actions, card_type="visa", error_type="timeout" ) assert prompt["card_type"] == "visa" @@ -1508,7 +1542,9 @@ def test_create_payment_action_say(self) -> None: def test_create_payment_action_play(self) -> None: """Test create_payment_action with Play type""" - action = FunctionResult.create_payment_action("Play", "https://example.com/prompt.mp3") + action = FunctionResult.create_payment_action( + "Play", "https://example.com/prompt.mp3" + ) assert action == {"type": "Play", "phrase": "https://example.com/prompt.mp3"} @@ -1612,7 +1648,7 @@ def test_toggle_functions(self) -> None: """Test toggling functions""" toggles = [ {"function": "get_weather", "active": True}, - {"function": "book_flight", "active": False} + {"function": "book_flight", "active": False}, ] result = FunctionResult().toggle_functions(toggles) assert result.action[0] == {"toggle_functions": toggles} @@ -1669,11 +1705,7 @@ class TestUpdateSettings: def test_update_settings(self) -> None: """Test updating agent runtime settings""" - settings = { - "temperature": 0.7, - "top-p": 0.9, - "confidence": 0.8 - } + settings = {"temperature": 0.7, "top-p": 0.9, "confidence": 0.8} result = FunctionResult().update_settings(settings) assert result.action[0] == {"settings": settings} @@ -1713,7 +1745,7 @@ def test_switch_context_full_object_with_all_params(self) -> None: system_prompt="New prompt", user_prompt="User msg", consolidate=True, - full_reset=True + full_reset=True, ) ctx = result.action[0]["context_switch"] @@ -1735,8 +1767,7 @@ def test_switch_context_with_full_reset_only(self) -> None: def test_switch_context_system_and_user_prompt(self) -> None: """Test switch_context with system_prompt and user_prompt uses object form""" result = FunctionResult().switch_context( - system_prompt="Sys", - user_prompt="User" + system_prompt="Sys", user_prompt="User" ) ctx = result.action[0]["context_switch"] @@ -1747,8 +1778,7 @@ def test_switch_context_system_and_user_prompt(self) -> None: def test_switch_context_system_prompt_with_consolidate(self) -> None: """Test switch_context with system_prompt and consolidate uses object form""" result = FunctionResult().switch_context( - system_prompt="New prompt", - consolidate=True + system_prompt="New prompt", consolidate=True ) ctx = result.action[0]["context_switch"] diff --git a/tests/unit/core/test_logging_config.py b/tests/unit/core/test_logging_config.py index 62ae4f28..453c9897 100644 --- a/tests/unit/core/test_logging_config.py +++ b/tests/unit/core/test_logging_config.py @@ -17,6 +17,7 @@ import os import sys from collections.abc import Iterator +from typing import Any from unittest.mock import patch from io import StringIO @@ -27,6 +28,7 @@ get_execution_mode, configure_logging, reset_logging_configuration, + strip_control_chars, ) @@ -34,6 +36,7 @@ # Helpers # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def _reset_logging(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: """Reset logging state before every test.""" @@ -59,36 +62,45 @@ class TestGetExecutionMode: """Test execution mode detection""" def test_cgi_mode_detection(self) -> None: - with patch.dict(os.environ, {'GATEWAY_INTERFACE': 'CGI/1.1'}, clear=False): - assert get_execution_mode() == 'cgi' + with patch.dict(os.environ, {"GATEWAY_INTERFACE": "CGI/1.1"}, clear=False): + assert get_execution_mode() == "cgi" def test_lambda_mode_detection(self) -> None: - with patch.dict(os.environ, {'AWS_LAMBDA_FUNCTION_NAME': 'test-function'}, clear=False): - assert get_execution_mode() == 'lambda' + with patch.dict( + os.environ, {"AWS_LAMBDA_FUNCTION_NAME": "test-function"}, clear=False + ): + assert get_execution_mode() == "lambda" def test_lambda_mode_detection_with_task_root(self) -> None: - with patch.dict(os.environ, {'LAMBDA_TASK_ROOT': '/var/task'}, clear=False): - assert get_execution_mode() == 'lambda' + with patch.dict(os.environ, {"LAMBDA_TASK_ROOT": "/var/task"}, clear=False): + assert get_execution_mode() == "lambda" def test_google_cloud_function_detection(self) -> None: - with patch.dict(os.environ, {'FUNCTION_TARGET': 'my_function'}, clear=False): - assert get_execution_mode() == 'google_cloud_function' + with patch.dict(os.environ, {"FUNCTION_TARGET": "my_function"}, clear=False): + assert get_execution_mode() == "google_cloud_function" def test_azure_function_detection(self) -> None: - with patch.dict(os.environ, {'AZURE_FUNCTIONS_ENVIRONMENT': 'Production'}, clear=False): - assert get_execution_mode() == 'azure_function' + with patch.dict( + os.environ, {"AZURE_FUNCTIONS_ENVIRONMENT": "Production"}, clear=False + ): + assert get_execution_mode() == "azure_function" def test_server_mode_default(self) -> None: env_vars_to_clear = [ - 'GATEWAY_INTERFACE', 'AWS_LAMBDA_FUNCTION_NAME', - 'LAMBDA_TASK_ROOT', 'FUNCTION_TARGET', 'K_SERVICE', - 'GOOGLE_CLOUD_PROJECT', 'AZURE_FUNCTIONS_ENVIRONMENT', - 'FUNCTIONS_WORKER_RUNTIME', 'AzureWebJobsStorage', + "GATEWAY_INTERFACE", + "AWS_LAMBDA_FUNCTION_NAME", + "LAMBDA_TASK_ROOT", + "FUNCTION_TARGET", + "K_SERVICE", + "GOOGLE_CLOUD_PROJECT", + "AZURE_FUNCTIONS_ENVIRONMENT", + "FUNCTIONS_WORKER_RUNTIME", + "AzureWebJobsStorage", ] with patch.dict(os.environ, {}, clear=False): for var in env_vars_to_clear: os.environ.pop(var, None) - assert get_execution_mode() == 'server' + assert get_execution_mode() == "server" # =========================================================================== @@ -102,11 +114,11 @@ class TestGetLogger: def test_returns_structlog_bound_logger(self) -> None: logger = get_logger("test_logger") # structlog BoundLoggers should have bind() - assert hasattr(logger, 'bind') - assert hasattr(logger, 'info') - assert hasattr(logger, 'debug') - assert hasattr(logger, 'warning') - assert hasattr(logger, 'error') + assert hasattr(logger, "bind") + assert hasattr(logger, "info") + assert hasattr(logger, "debug") + assert hasattr(logger, "warning") + assert hasattr(logger, "error") def test_different_names_create_different_loggers(self) -> None: logger1 = get_logger("logger_a") @@ -117,7 +129,7 @@ def test_does_not_trigger_configure_logging(self) -> None: # Library-safe (PY-8): get_logger must NOT auto-configure global logging. # Every SDK module calls get_logger() at import; auto-configuring would # hijack the host app's logging on first SDK submodule import. - with patch('signalwire.core.logging_config.configure_logging') as mock_conf: + with patch("signalwire.core.logging_config.configure_logging") as mock_conf: get_logger("test") mock_conf.assert_not_called() @@ -218,7 +230,7 @@ def test_default_mode(self) -> None: assert sw.propagate is False def test_off_mode(self) -> None: - with patch.dict(os.environ, {'SIGNALWIRE_LOG_MODE': 'off'}): + with patch.dict(os.environ, {"SIGNALWIRE_LOG_MODE": "off"}): configure_logging() sw = logging.getLogger("signalwire") # Off mode sets level above CRITICAL and no handlers @@ -226,7 +238,7 @@ def test_off_mode(self) -> None: assert len(sw.handlers) == 0 def test_stderr_mode(self) -> None: - with patch.dict(os.environ, {'SIGNALWIRE_LOG_MODE': 'stderr'}): + with patch.dict(os.environ, {"SIGNALWIRE_LOG_MODE": "stderr"}): configure_logging() sw = logging.getLogger("signalwire") assert len(sw.handlers) == 1 @@ -235,15 +247,17 @@ def test_stderr_mode(self) -> None: assert handler.stream is sys.stderr def test_auto_mode_cgi(self) -> None: - with patch.dict(os.environ, {'SIGNALWIRE_LOG_MODE': 'auto', 'GATEWAY_INTERFACE': 'CGI/1.1'}): + with patch.dict( + os.environ, {"SIGNALWIRE_LOG_MODE": "auto", "GATEWAY_INTERFACE": "CGI/1.1"} + ): configure_logging() sw = logging.getLogger("signalwire") # CGI → off mode assert sw.level > logging.CRITICAL def test_auto_mode_server(self) -> None: - env = {'SIGNALWIRE_LOG_MODE': 'auto'} - removals = ['GATEWAY_INTERFACE', 'AWS_LAMBDA_FUNCTION_NAME', 'LAMBDA_TASK_ROOT'] + env = {"SIGNALWIRE_LOG_MODE": "auto"} + removals = ["GATEWAY_INTERFACE", "AWS_LAMBDA_FUNCTION_NAME", "LAMBDA_TASK_ROOT"] with patch.dict(os.environ, env, clear=False): for v in removals: os.environ.pop(v, None) @@ -252,13 +266,13 @@ def test_auto_mode_server(self) -> None: assert len(sw.handlers) == 1 def test_log_level_env(self) -> None: - with patch.dict(os.environ, {'SIGNALWIRE_LOG_LEVEL': 'debug'}): + with patch.dict(os.environ, {"SIGNALWIRE_LOG_LEVEL": "debug"}): configure_logging() sw = logging.getLogger("signalwire") assert sw.level == logging.DEBUG def test_json_format(self) -> None: - with patch.dict(os.environ, {'SIGNALWIRE_LOG_FORMAT': 'json'}): + with patch.dict(os.environ, {"SIGNALWIRE_LOG_FORMAT": "json"}): configure_logging() sw = logging.getLogger("signalwire") assert len(sw.handlers) == 1 @@ -277,7 +291,10 @@ class TestStructuredLogging: def test_bind_adds_context(self) -> None: """bound fields should appear in the log output.""" - with patch.dict(os.environ, {'SIGNALWIRE_LOG_FORMAT': 'json', 'SIGNALWIRE_LOG_LEVEL': 'debug'}): + with patch.dict( + os.environ, + {"SIGNALWIRE_LOG_FORMAT": "json", "SIGNALWIRE_LOG_LEVEL": "debug"}, + ): configure_logging() buf = StringIO() @@ -297,7 +314,10 @@ def test_bind_adds_context(self) -> None: def test_nested_bind(self) -> None: """Multiple bind() calls should stack context.""" - with patch.dict(os.environ, {'SIGNALWIRE_LOG_FORMAT': 'json', 'SIGNALWIRE_LOG_LEVEL': 'debug'}): + with patch.dict( + os.environ, + {"SIGNALWIRE_LOG_FORMAT": "json", "SIGNALWIRE_LOG_LEVEL": "debug"}, + ): configure_logging() buf = StringIO() @@ -318,7 +338,10 @@ def test_nested_bind(self) -> None: def test_exc_info_produces_traceback(self) -> None: """exc_info should produce a real traceback, not 'exc_info=True' string.""" - with patch.dict(os.environ, {'SIGNALWIRE_LOG_FORMAT': 'json', 'SIGNALWIRE_LOG_LEVEL': 'debug'}): + with patch.dict( + os.environ, + {"SIGNALWIRE_LOG_FORMAT": "json", "SIGNALWIRE_LOG_LEVEL": "debug"}, + ): configure_logging() buf = StringIO() @@ -350,10 +373,12 @@ class TestOffModeNoFdLeak: """Off mode should not open /dev/null or any file.""" def test_no_file_handles_opened(self) -> None: - with patch.dict(os.environ, {'SIGNALWIRE_LOG_MODE': 'off'}): - with patch('builtins.open') as mock_open: - configure_logging() - mock_open.assert_not_called() + with ( + patch.dict(os.environ, {"SIGNALWIRE_LOG_MODE": "off"}), + patch("builtins.open") as mock_open, + ): + configure_logging() + mock_open.assert_not_called() # =========================================================================== @@ -384,7 +409,10 @@ class TestJsonMode: """Verify JSON mode produces parseable output with structured fields.""" def test_json_output(self) -> None: - with patch.dict(os.environ, {'SIGNALWIRE_LOG_FORMAT': 'json', 'SIGNALWIRE_LOG_LEVEL': 'debug'}): + with patch.dict( + os.environ, + {"SIGNALWIRE_LOG_FORMAT": "json", "SIGNALWIRE_LOG_LEVEL": "debug"}, + ): configure_logging() buf = StringIO() @@ -405,6 +433,126 @@ def test_json_output(self) -> None: assert data["event"] == "login" +# =========================================================================== +# Control-character stripping (log-injection prevention) +# =========================================================================== + + +class TestStripControlChars: + """`strip_control_chars` is a one-argument public function that scrubs control + characters from log event values; the structlog 3-argument processor protocol + is supplied by a private adapter at each registration site. + + These tests exercise the REAL configured chain (not the function in isolation) + so that a broken adapter at either registration site is caught. + """ + + def test_public_function_takes_only_the_event_dict(self) -> None: + """The public contract is one parameter: the event dict.""" + import inspect + + params = list(inspect.signature(strip_control_chars).parameters) + assert params == ["event_dict"] + + def test_strips_control_chars_from_values(self) -> None: + event_dict = {"event": "hello\x00world", "field": "a\x07b\x1fc", "n": 42} + result = strip_control_chars(event_dict) + assert result["event"] == "helloworld" + assert result["field"] == "abc" + # Non-string values pass through untouched. + assert result["n"] == 42 + + def test_registered_in_both_processor_chains(self) -> None: + """Both registration sites must carry the adapter, not the bare function.""" + # Resolve from the live module: other tests in this file call + # importlib.reload(), which rebinds these symbols to fresh objects. + import signalwire.core.logging_config as lc + + wrapped = lc._as_processor(lc.strip_control_chars) + chains = (lc._get_structlog_processors(), lc._get_formatter_processors()) + for chain in chains: + matches = [p for p in chain if p == wrapped] + assert matches, f"strip_control_chars adapter missing from {chain!r}" + + def test_adapter_is_callable_with_the_structlog_protocol(self) -> None: + """The adapter accepts structlog's (logger, method_name, event_dict) call.""" + from signalwire.core.logging_config import _as_processor + + processor = _as_processor(strip_control_chars) + result = processor(None, "info", {"event": "x\x00y"}) + assert result["event"] == "xy" + + def test_control_chars_stripped_through_the_real_chain(self) -> None: + """End-to-end: a control character in a logged value never reaches output. + + This drives BOTH registration sites: the structlog processor chain + (`_get_structlog_processors`) and the ProcessorFormatter chain + (`_get_formatter_processors`). + """ + with patch.dict( + os.environ, + {"SIGNALWIRE_LOG_FORMAT": "json", "SIGNALWIRE_LOG_LEVEL": "debug"}, + ): + configure_logging() + + buf = StringIO() + sw = logging.getLogger("signalwire") + handler = sw.handlers[0] + assert isinstance(handler, logging.StreamHandler) + handler.stream = buf + + log = get_logger("signalwire.test_control_chars") + # A forged log line: NUL + BEL in the event, ESC control byte in a + # bound field. + bound = log.bind(user="ali\x07ce", note="x\x1by") + bound.info("logged\x00in") + + output = buf.getvalue().strip() + assert output, "Expected JSON output" + data = json.loads(output) + + assert data["event"] == "loggedin" + assert data["user"] == "alice" + assert data["note"] == "xy" + + # Nothing in the raw rendered line carries a control character. + for ch in ("\x00", "\x07", "\x1b"): + assert ch not in output + + def test_both_chains_run_the_processor(self) -> None: + """Prove the processor is actually invoked twice per record — once by the + structlog chain and once by the ProcessorFormatter chain — so a silently + dropped registration site cannot pass.""" + import signalwire.core.logging_config as lc + + calls: list[dict[str, Any]] = [] + original = lc.strip_control_chars + + def spy(event_dict: dict[str, Any]) -> dict[str, Any]: + calls.append(event_dict) + return original(event_dict) + + with ( + patch.dict( + os.environ, + {"SIGNALWIRE_LOG_FORMAT": "json", "SIGNALWIRE_LOG_LEVEL": "debug"}, + ), + patch.object(lc, "strip_control_chars", spy), + ): + configure_logging() + + buf = StringIO() + sw = logging.getLogger("signalwire") + handler = sw.handlers[0] + assert isinstance(handler, logging.StreamHandler) + handler.stream = buf + + lc.get_logger("signalwire.test_both_chains").info("evt\x00x") + + assert len(calls) == 2, f"expected both chains to run it, got {len(calls)}" + assert buf.getvalue().strip() + + # =========================================================================== # Color detection # =========================================================================== @@ -415,28 +563,144 @@ class TestColorDetection: def test_no_colors_when_not_tty(self) -> None: from signalwire.core.logging_config import _detect_colors - with patch.object(sys, 'stdout', new_callable=StringIO): + + with patch.object(sys, "stdout", new_callable=StringIO): assert _detect_colors() is False def test_no_colors_when_dump_swml(self) -> None: from signalwire.core.logging_config import _detect_colors + original_argv = sys.argv[:] try: - sys.argv.append('--dump-swml') + sys.argv.append("--dump-swml") assert _detect_colors() is False finally: sys.argv[:] = original_argv def test_no_colors_when_raw(self) -> None: from signalwire.core.logging_config import _detect_colors + original_argv = sys.argv[:] try: - sys.argv.append('--raw') + sys.argv.append("--raw") assert _detect_colors() is False finally: sys.argv[:] = original_argv +# =========================================================================== +# Machine-readable stdout (#66): a CLI whose stdout IS the payload must never +# have a log line written to it. +# =========================================================================== + + +class TestMachineReadableStdout: + """``--raw`` / ``--dump-swml`` / ``--json`` make stdout a data channel. + + Two independent defects put log text on that channel, and both are covered + here because either one alone re-corrupts the output: + + 1. **The unconfigured default was stdout.** ``get_logger()`` deliberately + does not auto-configure (a library must not hijack the host's logging), + but structlog's *own* default factory is ``PrintLoggerFactory()``, which + writes to ``sys.stdout``. So "we never configured anything" did not mean + "silent" — it meant "print to stdout", and the ``NullHandler`` on the + ``signalwire`` stdlib logger never saw the record. A library that is + silent-by-default must route through stdlib from import. + 2. **The configured default was stdout too.** When an app DOES call + ``configure_logging()`` while a machine-readable flag is present, the + inferred ``default`` mode sent the handler to ``sys.stdout``. The flags + were consulted only by ``_detect_colors()`` — so they suppressed ANSI + colour but not the stream itself. + """ + + def test_flag_helper_lists_every_machine_readable_flag(self) -> None: + from signalwire.core.logging_config import _machine_readable_stdout + + original_argv = sys.argv[:] + for flag in ("--raw", "--dump-swml", "--json"): + try: + sys.argv[:] = ["prog", flag] + assert _machine_readable_stdout() is True, ( + f"{flag} must mark stdout as machine-readable" + ) + finally: + sys.argv[:] = original_argv + + try: + sys.argv[:] = ["prog", "--verbose"] + assert _machine_readable_stdout() is False + finally: + sys.argv[:] = original_argv + + def test_colour_detection_reuses_the_flag_helper(self) -> None: + # The bug was flag-list DRIFT: colour consulted the flags, the stream + # decision did not. Both must read the same helper so they cannot + # disagree again. + from signalwire.core.logging_config import _detect_colors + + with patch( + "signalwire.core.logging_config._machine_readable_stdout", + return_value=True, + ): + assert _detect_colors() is False + + def test_unconfigured_logger_does_not_write_to_stdout(self) -> None: + # Defect 1: the library default. No configure_logging() call at all. + # reset_logging_configuration() re-installs the library default, so this + # is exactly the state a host app sees on a fresh `import signalwire`. + reset_logging_configuration() + + buf = StringIO() + with patch.object(sys, "stdout", buf): + get_logger("signalwire.test.unconfigured").warning("must_not_appear") + + assert buf.getvalue() == "", ( + "an unconfigured SDK logger wrote to stdout; the library default " + f"must be silent, got: {buf.getvalue()!r}" + ) + + def test_inferred_mode_under_flag_goes_to_stderr_not_stdout(self) -> None: + # Defect 2: the configured default. Flag present, no explicit env mode. + original_argv = sys.argv[:] + try: + sys.argv[:] = ["swaig-test", "agent.py", "--dump-swml", "--raw"] + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("SIGNALWIRE_LOG_MODE", None) + reset_logging_configuration() + configure_logging() + finally: + sys.argv[:] = original_argv + + handlers = logging.getLogger("signalwire").handlers + streams = [h.stream for h in handlers if isinstance(h, logging.StreamHandler)] + assert streams, "configure_logging() attached no StreamHandler" + assert all(s is sys.stderr for s in streams), ( + f"logs must go to stderr under a machine-readable flag, got {streams}" + ) + + def test_explicit_env_mode_still_beats_the_flag(self) -> None: + # PRECEDENCE: an explicit SIGNALWIRE_LOG_MODE is a deliberate operator + # choice and outranks the flag INFERENCE. The flag only overrides the + # inferred default, never an explicit request. + original_argv = sys.argv[:] + try: + sys.argv[:] = ["swaig-test", "agent.py", "--dump-swml"] + with patch.dict(os.environ, {"SIGNALWIRE_LOG_MODE": "default"}): + reset_logging_configuration() + configure_logging() + finally: + sys.argv[:] = original_argv + + handlers = logging.getLogger("signalwire").handlers + streams = [h.stream for h in handlers if isinstance(h, logging.StreamHandler)] + assert streams, "configure_logging() attached no StreamHandler" + assert all(s is sys.stdout for s in streams), ( + "an explicit SIGNALWIRE_LOG_MODE=default must still win over the " + f"flag inference, got {streams}" + ) + + # =========================================================================== # reset_logging_configuration # =========================================================================== @@ -454,6 +718,6 @@ def test_reset_allows_reconfigure(self) -> None: sw.handlers.clear() sw.setLevel(logging.NOTSET) - with patch.dict(os.environ, {'SIGNALWIRE_LOG_MODE': 'off'}): + with patch.dict(os.environ, {"SIGNALWIRE_LOG_MODE": "off"}): configure_logging() assert sw.level > logging.CRITICAL diff --git a/tests/unit/core/test_pom_builder.py b/tests/unit/core/test_pom_builder.py index e9dbb9e2..42ac6ec7 100644 --- a/tests/unit/core/test_pom_builder.py +++ b/tests/unit/core/test_pom_builder.py @@ -11,9 +11,8 @@ Unit tests for POM builder module """ -import pytest -from unittest.mock import Mock, patch, MagicMock -from typing import Dict, List, Any, Optional +from unittest.mock import Mock, patch +from typing import Any # signalwire.pom is now vendored inside this package, so no mocks needed at import time from signalwire.core.pom_builder import PomBuilder @@ -21,411 +20,428 @@ class TestPomBuilder: """Test PomBuilder functionality""" - + def test_basic_initialization(self) -> None: """Test basic PomBuilder initialization""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: builder = PomBuilder() - + assert mock_pom.called assert builder._sections == {} - + def test_add_section_basic(self) -> None: """Test adding a basic section""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() - result = builder.add_section("Introduction", "This is the introduction section.") - + result = builder.add_section( + "Introduction", "This is the introduction section." + ) + assert result is builder # Should return self for chaining mock_pom.return_value.add_section.assert_called_with( title="Introduction", body="This is the introduction section.", bullets=[], numbered=False, - numberedBullets=False + numberedBullets=False, ) assert builder._sections["Introduction"] == mock_section - + def test_add_section_with_options(self) -> None: """Test adding a section with various options""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() bullets = ["Point 1", "Point 2"] - + result = builder.add_section( - "Features", + "Features", "Key features:", bullets=bullets, numbered=True, - numbered_bullets=True + numbered_bullets=True, ) - + assert result is builder mock_pom.return_value.add_section.assert_called_with( title="Features", body="Key features:", bullets=bullets, numbered=True, - numberedBullets=True + numberedBullets=True, ) - + def test_add_section_with_subsections(self) -> None: """Test adding a section with subsections""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() - subsections: List[Dict[str, Any]] = [ + subsections: list[dict[str, Any]] = [ {"title": "Sub 1", "body": "Content 1"}, - {"title": "Sub 2", "body": "Content 2", "bullets": ["bullet1"]} + {"title": "Sub 2", "body": "Content 2", "bullets": ["bullet1"]}, ] - + builder.add_section("Main Section", "Main content", subsections=subsections) - + # Verify subsections were added assert mock_section.add_subsection.call_count == 2 mock_section.add_subsection.assert_any_call( - title="Sub 1", - body="Content 1", - bullets=[] + title="Sub 1", body="Content 1", bullets=[] ) mock_section.add_subsection.assert_any_call( - title="Sub 2", - body="Content 2", - bullets=["bullet1"] + title="Sub 2", body="Content 2", bullets=["bullet1"] ) - + def test_add_to_section_new_section(self) -> None: """Test adding content to a new section (auto-vivification)""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_section.body = "" mock_section.bullets = [] mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() result = builder.add_to_section("New Section", body="Some content") - + assert result is builder # Should have created the section first mock_pom.return_value.add_section.assert_called() assert mock_section.body == "Some content" - + def test_add_to_section_existing_section(self) -> None: """Test adding content to an existing section""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_section.body = "Existing content" mock_section.bullets = [] mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() builder.add_section("Existing Section") - - result = builder.add_to_section("Existing Section", body="Additional content") - + + result = builder.add_to_section( + "Existing Section", body="Additional content" + ) + assert result is builder assert mock_section.body == "Existing content\n\nAdditional content" - + def test_add_to_section_bullets(self) -> None: """Test adding bullets to a section""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_section.bullets = [] mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() builder.add_section("Test Section") - + # Add single bullet builder.add_to_section("Test Section", bullet="Single bullet") assert "Single bullet" in mock_section.bullets - + # Add multiple bullets - fix the mock expectation mock_section.bullets = Mock() # Make bullets a mock with extend method builder.add_to_section("Test Section", bullets=["Bullet 1", "Bullet 2"]) mock_section.bullets.extend.assert_called_with(["Bullet 1", "Bullet 2"]) - + def test_add_subsection(self) -> None: """Test adding subsections""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_parent_section = Mock() mock_subsection = Mock() mock_parent_section.add_subsection.return_value = mock_subsection mock_pom.return_value.add_section.return_value = mock_parent_section - + builder = PomBuilder() builder.add_section("Parent Section") - + result = builder.add_subsection( - "Parent Section", - "Subsection Title", + "Parent Section", + "Subsection Title", "Subsection body", - bullets=["bullet1", "bullet2"] + bullets=["bullet1", "bullet2"], ) - + assert result is builder mock_parent_section.add_subsection.assert_called_with( title="Subsection Title", body="Subsection body", - bullets=["bullet1", "bullet2"] + bullets=["bullet1", "bullet2"], ) - + def test_add_subsection_auto_vivification(self) -> None: """Test adding subsection with auto-creation of parent""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_parent_section = Mock() mock_pom.return_value.add_section.return_value = mock_parent_section - + builder = PomBuilder() - + # Add subsection to non-existent parent builder.add_subsection("New Parent", "Subsection", "Content") - + # Should have created parent section mock_pom.return_value.add_section.assert_called() mock_parent_section.add_subsection.assert_called() - + def test_has_section(self) -> None: """Test checking if section exists""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() - + assert not builder.has_section("Nonexistent") - + builder.add_section("Existing Section") assert builder.has_section("Existing Section") - + def test_get_section(self) -> None: """Test getting section by title""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() - + assert builder.get_section("Nonexistent") is None - + builder.add_section("Test Section") assert builder.get_section("Test Section") == mock_section - + def test_render_methods(self) -> None: """Test rendering methods""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_pom.return_value.render_markdown.return_value = "# Markdown" mock_pom.return_value.render_xml.return_value = "content" - + builder = PomBuilder() - + assert builder.render_markdown() == "# Markdown" assert builder.render_xml() == "content" - + mock_pom.return_value.render_markdown.assert_called_once() mock_pom.return_value.render_xml.assert_called_once() - + def test_to_dict_and_to_json(self) -> None: """Test conversion methods""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_dict = [{"title": "Section", "body": "Content"}] mock_json = '{"sections": []}' mock_pom.return_value.to_dict.return_value = mock_dict mock_pom.return_value.to_json.return_value = mock_json - + builder = PomBuilder() - + assert builder.to_dict() == mock_dict assert builder.to_json() == mock_json - + mock_pom.return_value.to_dict.assert_called_once() mock_pom.return_value.to_json.assert_called_once() - + def test_from_sections_classmethod(self) -> None: """Test creating PomBuilder from sections""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_pom_instance = Mock() # Fix the mock to have iterable sections mock_pom_instance.sections = [Mock(name="section1"), Mock(name="section2")] mock_pom.from_json.return_value = mock_pom_instance - + sections = [{"title": "Section 1", "body": "Content 1"}] - + builder = PomBuilder.from_sections(sections) assert builder.pom is mock_pom_instance mock_pom.from_json.assert_called_once_with(sections) - + def test_method_chaining(self) -> None: """Test method chaining functionality""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_section.bullets = [] mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() - + # Test chaining multiple operations - result = (builder - .add_section("Section 1", "Content 1") - .add_section("Section 2", "Content 2") - .add_to_section("Section 1", bullet="New bullet") - .add_subsection("Section 2", "Subsection", "Sub content")) - + result = ( + builder.add_section("Section 1", "Content 1") + .add_section("Section 2", "Content 2") + .add_to_section("Section 1", bullet="New bullet") + .add_subsection("Section 2", "Subsection", "Sub content") + ) + assert result is builder assert mock_pom.return_value.add_section.call_count == 2 class TestPomBuilderIntegration: """Test PomBuilder integration scenarios""" - + def test_complex_document_building(self) -> None: """Test building a complex document structure""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_section.bullets = [] mock_section.body = "" mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() - + # Build a complex document builder.add_section("Introduction", "Welcome to our guide") - + builder.add_section("Features", "Key features include:") - builder.add_to_section("Features", bullets=[ - "Easy to use", - "Highly configurable", - "Well documented" - ]) - + builder.add_to_section( + "Features", + bullets=["Easy to use", "Highly configurable", "Well documented"], + ) + builder.add_section("Getting Started") - builder.add_subsection("Getting Started", "Installation", "Run pip install...") - builder.add_subsection("Getting Started", "Configuration", "Set up your config...") - + builder.add_subsection( + "Getting Started", "Installation", "Run pip install..." + ) + builder.add_subsection( + "Getting Started", "Configuration", "Set up your config..." + ) + builder.add_section("Advanced Topics", numbered=True) builder.add_to_section("Advanced Topics", body="For advanced users...") - + # Verify the structure was built correctly assert len(builder._sections) == 4 assert "Introduction" in builder._sections assert "Features" in builder._sections assert "Getting Started" in builder._sections assert "Advanced Topics" in builder._sections - + def test_agent_prompt_building(self) -> None: """Test building agent prompts using PomBuilder""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_section.bullets = [] mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() - + # Build a comprehensive agent prompt builder.add_section( - "Role Definition", - "You are a helpful customer service agent." + "Role Definition", "You are a helpful customer service agent." ) - + builder.add_section("Capabilities") - builder.add_to_section("Capabilities", bullets=[ - "Answer questions about products", - "Help with account issues", - "Escalate complex problems" - ]) - + builder.add_to_section( + "Capabilities", + bullets=[ + "Answer questions about products", + "Help with account issues", + "Escalate complex problems", + ], + ) + builder.add_section("Guidelines") - builder.add_subsection("Guidelines", "Tone", "Always be polite and professional") - builder.add_subsection("Guidelines", "Accuracy", "Provide accurate information") - + builder.add_subsection( + "Guidelines", "Tone", "Always be polite and professional" + ) + builder.add_subsection( + "Guidelines", "Accuracy", "Provide accurate information" + ) + builder.add_section("Escalation", "When to escalate:") - builder.add_to_section("Escalation", bullets=[ - "Technical issues beyond scope", - "Billing disputes over $100", - "Customer requests supervisor" - ]) - + builder.add_to_section( + "Escalation", + bullets=[ + "Technical issues beyond scope", + "Billing disputes over $100", + "Customer requests supervisor", + ], + ) + # Verify the prompt structure assert len(builder._sections) == 4 assert builder.has_section("Role Definition") assert builder.has_section("Capabilities") assert builder.has_section("Guidelines") assert builder.has_section("Escalation") - + def test_documentation_generation(self) -> None: """Test generating API documentation""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_section.bullets = [] mock_section.body = "" mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() - + # Build API documentation builder.add_section("API Overview", "This API provides...") - + builder.add_section("Authentication") - builder.add_to_section("Authentication", body="All requests require authentication") + builder.add_to_section( + "Authentication", body="All requests require authentication" + ) builder.add_subsection("Authentication", "API Keys", "Use Bearer tokens") - builder.add_subsection("Authentication", "Rate Limits", "1000 requests per hour") - + builder.add_subsection( + "Authentication", "Rate Limits", "1000 requests per hour" + ) + builder.add_section("Endpoints") - + # Add endpoint documentation endpoints = [ ("GET /users", "Retrieve user list"), ("POST /users", "Create new user"), ("GET /users/{id}", "Get specific user"), ("PUT /users/{id}", "Update user"), - ("DELETE /users/{id}", "Delete user") + ("DELETE /users/{id}", "Delete user"), ] - + for endpoint, description in endpoints: builder.add_subsection("Endpoints", endpoint, description) - + builder.add_section("Examples") builder.add_to_section("Examples", body="Here are some usage examples:") - + # Verify documentation structure assert len(builder._sections) == 4 assert builder.has_section("API Overview") assert builder.has_section("Authentication") assert builder.has_section("Endpoints") assert builder.has_section("Examples") - + def test_error_recovery_and_flexibility(self) -> None: """Test error recovery and flexible usage patterns""" - with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: + with patch("signalwire.core.pom_builder.PromptObjectModel") as mock_pom: mock_section = Mock() mock_section.bullets = [] mock_section.body = "" mock_pom.return_value.add_section.return_value = mock_section - + builder = PomBuilder() - + # Test adding content to non-existent sections (auto-vivification) builder.add_to_section("Auto Created", body="This section was auto-created") assert builder.has_section("Auto Created") - + # Test adding subsections to non-existent parents builder.add_subsection("Another Auto", "Sub", "Subsection content") assert builder.has_section("Another Auto") - + # Test multiple additions to same section builder.add_to_section("Auto Created", bullet="First bullet") builder.add_to_section("Auto Created", bullets=["Second", "Third"]) builder.add_to_section("Auto Created", body="Additional content") - + # Verify flexibility assert len(builder._sections) == 2 assert builder.get_section("Auto Created") is not None - assert builder.get_section("Another Auto") is not None \ No newline at end of file + assert builder.get_section("Another Auto") is not None diff --git a/tests/unit/core/test_security_config.py b/tests/unit/core/test_security_config.py index bf33e35e..78c531a0 100644 --- a/tests/unit/core/test_security_config.py +++ b/tests/unit/core/test_security_config.py @@ -12,10 +12,8 @@ """ import os -import secrets from typing import TYPE_CHECKING, Any -import pytest -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import patch, MagicMock if TYPE_CHECKING: from signalwire.core.security_config import SecurityConfig @@ -26,11 +24,20 @@ # We patch at the module level where needed. ENV_CLEAR_KEYS = [ - 'SWML_SSL_ENABLED', 'SWML_SSL_CERT_PATH', 'SWML_SSL_KEY_PATH', - 'SWML_DOMAIN', 'SWML_SSL_VERIFY_MODE', 'SWML_ALLOWED_HOSTS', - 'SWML_CORS_ORIGINS', 'SWML_MAX_REQUEST_SIZE', 'SWML_RATE_LIMIT', - 'SWML_REQUEST_TIMEOUT', 'SWML_USE_HSTS', 'SWML_HSTS_MAX_AGE', - 'SWML_BASIC_AUTH_USER', 'SWML_BASIC_AUTH_PASSWORD', + "SWML_SSL_ENABLED", + "SWML_SSL_CERT_PATH", + "SWML_SSL_KEY_PATH", + "SWML_DOMAIN", + "SWML_SSL_VERIFY_MODE", + "SWML_ALLOWED_HOSTS", + "SWML_CORS_ORIGINS", + "SWML_MAX_REQUEST_SIZE", + "SWML_RATE_LIMIT", + "SWML_REQUEST_TIMEOUT", + "SWML_USE_HSTS", + "SWML_HSTS_MAX_AGE", + "SWML_BASIC_AUTH_USER", + "SWML_BASIC_AUTH_PASSWORD", ] @@ -46,13 +53,16 @@ def _make_config(**env_overrides: Any) -> "SecurityConfig": """ clean = {k: v for k, v in os.environ.items() if k not in ENV_CLEAR_KEYS} clean.update(env_overrides) - with patch.dict(os.environ, clean, clear=True): - with patch( - 'signalwire.core.security_config.ConfigLoader.find_config_file', + with ( + patch.dict(os.environ, clean, clear=True), + patch( + "signalwire.core.security_config.ConfigLoader.find_config_file", return_value=None, - ): - from signalwire.core.security_config import SecurityConfig - return SecurityConfig() + ), + ): + from signalwire.core.security_config import SecurityConfig + + return SecurityConfig() class TestSecurityConfigClassAttributes: @@ -60,39 +70,43 @@ class TestSecurityConfigClassAttributes: def test_ssl_env_var_names(self) -> None: from signalwire.core.security_config import SecurityConfig - assert SecurityConfig.SSL_ENABLED == 'SWML_SSL_ENABLED' - assert SecurityConfig.SSL_CERT_PATH == 'SWML_SSL_CERT_PATH' - assert SecurityConfig.SSL_KEY_PATH == 'SWML_SSL_KEY_PATH' - assert SecurityConfig.SSL_DOMAIN == 'SWML_DOMAIN' - assert SecurityConfig.SSL_VERIFY_MODE == 'SWML_SSL_VERIFY_MODE' + + assert SecurityConfig.SSL_ENABLED == "SWML_SSL_ENABLED" + assert SecurityConfig.SSL_CERT_PATH == "SWML_SSL_CERT_PATH" + assert SecurityConfig.SSL_KEY_PATH == "SWML_SSL_KEY_PATH" + assert SecurityConfig.SSL_DOMAIN == "SWML_DOMAIN" + assert SecurityConfig.SSL_VERIFY_MODE == "SWML_SSL_VERIFY_MODE" def test_additional_env_var_names(self) -> None: from signalwire.core.security_config import SecurityConfig - assert SecurityConfig.ALLOWED_HOSTS == 'SWML_ALLOWED_HOSTS' - assert SecurityConfig.CORS_ORIGINS == 'SWML_CORS_ORIGINS' - assert SecurityConfig.MAX_REQUEST_SIZE == 'SWML_MAX_REQUEST_SIZE' - assert SecurityConfig.RATE_LIMIT == 'SWML_RATE_LIMIT' - assert SecurityConfig.REQUEST_TIMEOUT == 'SWML_REQUEST_TIMEOUT' - assert SecurityConfig.USE_HSTS == 'SWML_USE_HSTS' - assert SecurityConfig.HSTS_MAX_AGE == 'SWML_HSTS_MAX_AGE' + + assert SecurityConfig.ALLOWED_HOSTS == "SWML_ALLOWED_HOSTS" + assert SecurityConfig.CORS_ORIGINS == "SWML_CORS_ORIGINS" + assert SecurityConfig.MAX_REQUEST_SIZE == "SWML_MAX_REQUEST_SIZE" + assert SecurityConfig.RATE_LIMIT == "SWML_RATE_LIMIT" + assert SecurityConfig.REQUEST_TIMEOUT == "SWML_REQUEST_TIMEOUT" + assert SecurityConfig.USE_HSTS == "SWML_USE_HSTS" + assert SecurityConfig.HSTS_MAX_AGE == "SWML_HSTS_MAX_AGE" def test_auth_env_var_names(self) -> None: from signalwire.core.security_config import SecurityConfig - assert SecurityConfig.BASIC_AUTH_USER == 'SWML_BASIC_AUTH_USER' - assert SecurityConfig.BASIC_AUTH_PASSWORD == 'SWML_BASIC_AUTH_PASSWORD' + + assert SecurityConfig.BASIC_AUTH_USER == "SWML_BASIC_AUTH_USER" + assert SecurityConfig.BASIC_AUTH_PASSWORD == "SWML_BASIC_AUTH_PASSWORD" def test_defaults_dict_contains_expected_keys(self) -> None: from signalwire.core.security_config import SecurityConfig + defaults = SecurityConfig.DEFAULTS - assert defaults['SWML_SSL_ENABLED'] is False - assert defaults['SWML_SSL_VERIFY_MODE'] == 'CERT_REQUIRED' - assert defaults['SWML_ALLOWED_HOSTS'] == '*' - assert defaults['SWML_CORS_ORIGINS'] == '*' - assert defaults['SWML_MAX_REQUEST_SIZE'] == 10 * 1024 * 1024 - assert defaults['SWML_RATE_LIMIT'] == 60 - assert defaults['SWML_REQUEST_TIMEOUT'] == 30 - assert defaults['SWML_USE_HSTS'] is True - assert defaults['SWML_HSTS_MAX_AGE'] == 31536000 + assert defaults["SWML_SSL_ENABLED"] is False + assert defaults["SWML_SSL_VERIFY_MODE"] == "CERT_REQUIRED" + assert defaults["SWML_ALLOWED_HOSTS"] == "*" + assert defaults["SWML_CORS_ORIGINS"] == "*" + assert defaults["SWML_MAX_REQUEST_SIZE"] == 10 * 1024 * 1024 + assert defaults["SWML_RATE_LIMIT"] == 60 + assert defaults["SWML_REQUEST_TIMEOUT"] == 30 + assert defaults["SWML_USE_HSTS"] is True + assert defaults["SWML_HSTS_MAX_AGE"] == 31536000 class TestSecurityConfigDefaults: @@ -104,12 +118,12 @@ def test_ssl_defaults(self) -> None: assert cfg.ssl_cert_path is None assert cfg.ssl_key_path is None assert cfg.domain is None - assert cfg.ssl_verify_mode == 'CERT_REQUIRED' + assert cfg.ssl_verify_mode == "CERT_REQUIRED" def test_host_and_cors_defaults(self) -> None: cfg = _make_config() - assert cfg.allowed_hosts == ['*'] - assert cfg.cors_origins == ['*'] + assert cfg.allowed_hosts == ["*"] + assert cfg.cors_origins == ["*"] def test_numeric_defaults(self) -> None: cfg = _make_config() @@ -136,139 +150,139 @@ def _get_instance(self) -> "SecurityConfig": def test_wildcard_string(self) -> None: cfg = self._get_instance() - assert cfg._parse_list('*') == ['*'] + assert cfg._parse_list("*") == ["*"] def test_single_value(self) -> None: cfg = self._get_instance() - assert cfg._parse_list('example.com') == ['example.com'] + assert cfg._parse_list("example.com") == ["example.com"] def test_comma_separated(self) -> None: cfg = self._get_instance() - result = cfg._parse_list('a.com,b.com,c.com') - assert result == ['a.com', 'b.com', 'c.com'] + result = cfg._parse_list("a.com,b.com,c.com") + assert result == ["a.com", "b.com", "c.com"] def test_comma_separated_with_spaces(self) -> None: cfg = self._get_instance() - result = cfg._parse_list(' a.com , b.com , c.com ') - assert result == ['a.com', 'b.com', 'c.com'] + result = cfg._parse_list(" a.com , b.com , c.com ") + assert result == ["a.com", "b.com", "c.com"] def test_list_input_passthrough(self) -> None: cfg = self._get_instance() - input_list = ['x.com', 'y.com'] + input_list = ["x.com", "y.com"] assert cfg._parse_list(input_list) is input_list def test_empty_string(self) -> None: cfg = self._get_instance() - assert cfg._parse_list('') == [] + assert cfg._parse_list("") == [] def test_only_commas(self) -> None: cfg = self._get_instance() - assert cfg._parse_list(',,,') == [] + assert cfg._parse_list(",,,") == [] def test_trailing_comma(self) -> None: cfg = self._get_instance() - assert cfg._parse_list('a.com,b.com,') == ['a.com', 'b.com'] + assert cfg._parse_list("a.com,b.com,") == ["a.com", "b.com"] class TestLoadFromEnv: """Test loading configuration from environment variables.""" def test_ssl_enabled_true(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='true') + cfg = _make_config(SWML_SSL_ENABLED="true") assert cfg.ssl_enabled is True def test_ssl_enabled_1(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='1') + cfg = _make_config(SWML_SSL_ENABLED="1") assert cfg.ssl_enabled is True def test_ssl_enabled_yes(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='yes') + cfg = _make_config(SWML_SSL_ENABLED="yes") assert cfg.ssl_enabled is True def test_ssl_enabled_false(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='false') + cfg = _make_config(SWML_SSL_ENABLED="false") assert cfg.ssl_enabled is False def test_ssl_enabled_empty(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='') + cfg = _make_config(SWML_SSL_ENABLED="") assert cfg.ssl_enabled is False def test_ssl_enabled_case_insensitive(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='TRUE') + cfg = _make_config(SWML_SSL_ENABLED="TRUE") assert cfg.ssl_enabled is True def test_ssl_enabled_yes_uppercase(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='YES') + cfg = _make_config(SWML_SSL_ENABLED="YES") assert cfg.ssl_enabled is True def test_ssl_cert_and_key_paths(self) -> None: cfg = _make_config( - SWML_SSL_CERT_PATH='/path/to/cert.pem', - SWML_SSL_KEY_PATH='/path/to/key.pem', + SWML_SSL_CERT_PATH="/path/to/cert.pem", + SWML_SSL_KEY_PATH="/path/to/key.pem", ) - assert cfg.ssl_cert_path == '/path/to/cert.pem' - assert cfg.ssl_key_path == '/path/to/key.pem' + assert cfg.ssl_cert_path == "/path/to/cert.pem" + assert cfg.ssl_key_path == "/path/to/key.pem" def test_domain(self) -> None: - cfg = _make_config(SWML_DOMAIN='example.com') - assert cfg.domain == 'example.com' + cfg = _make_config(SWML_DOMAIN="example.com") + assert cfg.domain == "example.com" def test_ssl_verify_mode(self) -> None: - cfg = _make_config(SWML_SSL_VERIFY_MODE='CERT_OPTIONAL') - assert cfg.ssl_verify_mode == 'CERT_OPTIONAL' + cfg = _make_config(SWML_SSL_VERIFY_MODE="CERT_OPTIONAL") + assert cfg.ssl_verify_mode == "CERT_OPTIONAL" def test_allowed_hosts(self) -> None: - cfg = _make_config(SWML_ALLOWED_HOSTS='a.com,b.com') - assert cfg.allowed_hosts == ['a.com', 'b.com'] + cfg = _make_config(SWML_ALLOWED_HOSTS="a.com,b.com") + assert cfg.allowed_hosts == ["a.com", "b.com"] def test_cors_origins(self) -> None: - cfg = _make_config(SWML_CORS_ORIGINS='http://localhost:3000,http://app.com') - assert cfg.cors_origins == ['http://localhost:3000', 'http://app.com'] + cfg = _make_config(SWML_CORS_ORIGINS="http://localhost:3000,http://app.com") + assert cfg.cors_origins == ["http://localhost:3000", "http://app.com"] def test_max_request_size(self) -> None: - cfg = _make_config(SWML_MAX_REQUEST_SIZE='5242880') + cfg = _make_config(SWML_MAX_REQUEST_SIZE="5242880") assert cfg.max_request_size == 5242880 def test_rate_limit(self) -> None: - cfg = _make_config(SWML_RATE_LIMIT='120') + cfg = _make_config(SWML_RATE_LIMIT="120") assert cfg.rate_limit == 120 def test_request_timeout(self) -> None: - cfg = _make_config(SWML_REQUEST_TIMEOUT='60') + cfg = _make_config(SWML_REQUEST_TIMEOUT="60") assert cfg.request_timeout == 60 def test_hsts_max_age(self) -> None: - cfg = _make_config(SWML_HSTS_MAX_AGE='86400') + cfg = _make_config(SWML_HSTS_MAX_AGE="86400") assert cfg.hsts_max_age == 86400 def test_use_hsts_false(self) -> None: - cfg = _make_config(SWML_USE_HSTS='false') + cfg = _make_config(SWML_USE_HSTS="false") assert cfg.use_hsts is False def test_use_hsts_non_false_value(self) -> None: - cfg = _make_config(SWML_USE_HSTS='true') + cfg = _make_config(SWML_USE_HSTS="true") assert cfg.use_hsts is True def test_use_hsts_arbitrary_string(self) -> None: """Non-'false' strings should result in truthy use_hsts.""" - cfg = _make_config(SWML_USE_HSTS='anything') + cfg = _make_config(SWML_USE_HSTS="anything") assert cfg.use_hsts is True def test_basic_auth_user(self) -> None: - cfg = _make_config(SWML_BASIC_AUTH_USER='admin') - assert cfg.basic_auth_user == 'admin' + cfg = _make_config(SWML_BASIC_AUTH_USER="admin") + assert cfg.basic_auth_user == "admin" def test_basic_auth_password(self) -> None: - cfg = _make_config(SWML_BASIC_AUTH_PASSWORD='secret123') - assert cfg.basic_auth_password == 'secret123' + cfg = _make_config(SWML_BASIC_AUTH_PASSWORD="secret123") + assert cfg.basic_auth_password == "secret123" def test_basic_auth_both(self) -> None: cfg = _make_config( - SWML_BASIC_AUTH_USER='myuser', - SWML_BASIC_AUTH_PASSWORD='mypass', + SWML_BASIC_AUTH_USER="myuser", + SWML_BASIC_AUTH_PASSWORD="mypass", ) - assert cfg.basic_auth_user == 'myuser' - assert cfg.basic_auth_password == 'mypass' + assert cfg.basic_auth_user == "myuser" + assert cfg.basic_auth_password == "mypass" class TestLoadConfigFile: @@ -283,17 +297,20 @@ def _make_config_with_file( mock_config_loader_instance.get_section.return_value = security_section clean = {k: v for k, v in os.environ.items() if k not in ENV_CLEAR_KEYS} - with patch.dict(os.environ, clean, clear=True): - with patch( - 'signalwire.core.security_config.ConfigLoader.find_config_file', - return_value='/fake/config.json', - ): - with patch( - 'signalwire.core.security_config.ConfigLoader', - return_value=mock_config_loader_instance, - ): - from signalwire.core.security_config import SecurityConfig - return SecurityConfig() + with ( + patch.dict(os.environ, clean, clear=True), + patch( + "signalwire.core.security_config.ConfigLoader.find_config_file", + return_value="/fake/config.json", + ), + patch( + "signalwire.core.security_config.ConfigLoader", + return_value=mock_config_loader_instance, + ), + ): + from signalwire.core.security_config import SecurityConfig + + return SecurityConfig() def test_no_config_file_found(self) -> None: """When find_config_file returns None, config file loading is skipped.""" @@ -314,96 +331,106 @@ def test_empty_security_section(self) -> None: assert cfg.rate_limit == 60 def test_ssl_enabled_from_config(self) -> None: - cfg = self._make_config_with_file({'ssl_enabled': True}) + cfg = self._make_config_with_file({"ssl_enabled": True}) assert cfg.ssl_enabled is True def test_ssl_cert_key_from_config(self) -> None: - cfg = self._make_config_with_file({ - 'ssl_cert_path': '/config/cert.pem', - 'ssl_key_path': '/config/key.pem', - }) - assert cfg.ssl_cert_path == '/config/cert.pem' - assert cfg.ssl_key_path == '/config/key.pem' + cfg = self._make_config_with_file( + { + "ssl_cert_path": "/config/cert.pem", + "ssl_key_path": "/config/key.pem", + } + ) + assert cfg.ssl_cert_path == "/config/cert.pem" + assert cfg.ssl_key_path == "/config/key.pem" def test_domain_from_config(self) -> None: - cfg = self._make_config_with_file({'domain': 'config.example.com'}) - assert cfg.domain == 'config.example.com' + cfg = self._make_config_with_file({"domain": "config.example.com"}) + assert cfg.domain == "config.example.com" def test_ssl_verify_mode_from_config(self) -> None: - cfg = self._make_config_with_file({'ssl_verify_mode': 'CERT_NONE'}) - assert cfg.ssl_verify_mode == 'CERT_NONE' + cfg = self._make_config_with_file({"ssl_verify_mode": "CERT_NONE"}) + assert cfg.ssl_verify_mode == "CERT_NONE" def test_allowed_hosts_from_config_string(self) -> None: - cfg = self._make_config_with_file({'allowed_hosts': 'host1.com,host2.com'}) - assert cfg.allowed_hosts == ['host1.com', 'host2.com'] + cfg = self._make_config_with_file({"allowed_hosts": "host1.com,host2.com"}) + assert cfg.allowed_hosts == ["host1.com", "host2.com"] def test_allowed_hosts_from_config_list(self) -> None: - cfg = self._make_config_with_file({'allowed_hosts': ['host1.com', 'host2.com']}) - assert cfg.allowed_hosts == ['host1.com', 'host2.com'] + cfg = self._make_config_with_file({"allowed_hosts": ["host1.com", "host2.com"]}) + assert cfg.allowed_hosts == ["host1.com", "host2.com"] def test_cors_origins_from_config(self) -> None: - cfg = self._make_config_with_file({'cors_origins': 'http://app.com'}) - assert cfg.cors_origins == ['http://app.com'] + cfg = self._make_config_with_file({"cors_origins": "http://app.com"}) + assert cfg.cors_origins == ["http://app.com"] def test_numeric_settings_from_config(self) -> None: - cfg = self._make_config_with_file({ - 'max_request_size': '2097152', - 'rate_limit': '100', - 'request_timeout': '45', - 'hsts_max_age': '7200', - }) + cfg = self._make_config_with_file( + { + "max_request_size": "2097152", + "rate_limit": "100", + "request_timeout": "45", + "hsts_max_age": "7200", + } + ) assert cfg.max_request_size == 2097152 assert cfg.rate_limit == 100 assert cfg.request_timeout == 45 assert cfg.hsts_max_age == 7200 def test_use_hsts_from_config(self) -> None: - cfg = self._make_config_with_file({'use_hsts': False}) + cfg = self._make_config_with_file({"use_hsts": False}) assert cfg.use_hsts is False def test_auth_from_config(self) -> None: - cfg = self._make_config_with_file({ - 'auth': { - 'basic': { - 'user': 'config_user', - 'password': 'config_pass', + cfg = self._make_config_with_file( + { + "auth": { + "basic": { + "user": "config_user", + "password": "config_pass", + } } } - }) - assert cfg.basic_auth_user == 'config_user' - assert cfg.basic_auth_password == 'config_pass' + ) + assert cfg.basic_auth_user == "config_user" + assert cfg.basic_auth_password == "config_pass" def test_auth_partial_user_only(self) -> None: - cfg = self._make_config_with_file({ - 'auth': { - 'basic': { - 'user': 'just_user', + cfg = self._make_config_with_file( + { + "auth": { + "basic": { + "user": "just_user", + } } } - }) - assert cfg.basic_auth_user == 'just_user' + ) + assert cfg.basic_auth_user == "just_user" assert cfg.basic_auth_password is None def test_auth_partial_password_only(self) -> None: - cfg = self._make_config_with_file({ - 'auth': { - 'basic': { - 'password': 'just_pass', + cfg = self._make_config_with_file( + { + "auth": { + "basic": { + "password": "just_pass", + } } } - }) + ) assert cfg.basic_auth_user is None - assert cfg.basic_auth_password == 'just_pass' + assert cfg.basic_auth_password == "just_pass" def test_auth_not_dict_ignored(self) -> None: """If auth is not a dict, it should be ignored gracefully.""" - cfg = self._make_config_with_file({'auth': 'not_a_dict'}) + cfg = self._make_config_with_file({"auth": "not_a_dict"}) assert cfg.basic_auth_user is None assert cfg.basic_auth_password is None def test_auth_basic_not_dict_ignored(self) -> None: """If auth.basic is not a dict, it should be ignored gracefully.""" - cfg = self._make_config_with_file({'auth': {'basic': 'not_a_dict'}}) + cfg = self._make_config_with_file({"auth": {"basic": "not_a_dict"}}) assert cfg.basic_auth_user is None assert cfg.basic_auth_password is None @@ -412,47 +439,53 @@ def test_config_file_overrides_env(self) -> None: mock_config_loader_instance = MagicMock() mock_config_loader_instance.has_config.return_value = True mock_config_loader_instance.get_section.return_value = { - 'rate_limit': '200', - 'domain': 'config-domain.com', + "rate_limit": "200", + "domain": "config-domain.com", } clean = {k: v for k, v in os.environ.items() if k not in ENV_CLEAR_KEYS} - clean['SWML_RATE_LIMIT'] = '50' - clean['SWML_DOMAIN'] = 'env-domain.com' - - with patch.dict(os.environ, clean, clear=True): - with patch( - 'signalwire.core.security_config.ConfigLoader.find_config_file', - return_value='/fake/config.json', - ): - with patch( - 'signalwire.core.security_config.ConfigLoader', - return_value=mock_config_loader_instance, - ): - from signalwire.core.security_config import SecurityConfig - cfg = SecurityConfig() + clean["SWML_RATE_LIMIT"] = "50" + clean["SWML_DOMAIN"] = "env-domain.com" + + with ( + patch.dict(os.environ, clean, clear=True), + patch( + "signalwire.core.security_config.ConfigLoader.find_config_file", + return_value="/fake/config.json", + ), + patch( + "signalwire.core.security_config.ConfigLoader", + return_value=mock_config_loader_instance, + ), + ): + from signalwire.core.security_config import SecurityConfig + + cfg = SecurityConfig() # Config file values should win assert cfg.rate_limit == 200 - assert cfg.domain == 'config-domain.com' + assert cfg.domain == "config-domain.com" def test_explicit_config_file_path(self) -> None: """When config_file is passed explicitly, find_config_file should not be called.""" mock_config_loader_instance = MagicMock() mock_config_loader_instance.has_config.return_value = True - mock_config_loader_instance.get_section.return_value = {'rate_limit': '999'} + mock_config_loader_instance.get_section.return_value = {"rate_limit": "999"} clean = {k: v for k, v in os.environ.items() if k not in ENV_CLEAR_KEYS} - with patch.dict(os.environ, clean, clear=True): - with patch( - 'signalwire.core.security_config.ConfigLoader.find_config_file', - ) as mock_find: - with patch( - 'signalwire.core.security_config.ConfigLoader', - return_value=mock_config_loader_instance, - ): - from signalwire.core.security_config import SecurityConfig - cfg = SecurityConfig(config_file='/explicit/path.json') + with ( + patch.dict(os.environ, clean, clear=True), + patch( + "signalwire.core.security_config.ConfigLoader.find_config_file", + ) as mock_find, + patch( + "signalwire.core.security_config.ConfigLoader", + return_value=mock_config_loader_instance, + ), + ): + from signalwire.core.security_config import SecurityConfig + + cfg = SecurityConfig(config_file="/explicit/path.json") mock_find.assert_not_called() assert cfg.rate_limit == 999 @@ -460,15 +493,18 @@ def test_explicit_config_file_path(self) -> None: def test_service_name_passed_to_find_config(self) -> None: """service_name should be forwarded to find_config_file when no config_file given.""" clean = {k: v for k, v in os.environ.items() if k not in ENV_CLEAR_KEYS} - with patch.dict(os.environ, clean, clear=True): - with patch( - 'signalwire.core.security_config.ConfigLoader.find_config_file', + with ( + patch.dict(os.environ, clean, clear=True), + patch( + "signalwire.core.security_config.ConfigLoader.find_config_file", return_value=None, - ) as mock_find: - from signalwire.core.security_config import SecurityConfig - SecurityConfig(service_name='my_service') + ) as mock_find, + ): + from signalwire.core.security_config import SecurityConfig + + SecurityConfig(service_name="my_service") - mock_find.assert_called_once_with('my_service') + mock_find.assert_called_once_with("my_service") class TestValidateSSLConfig: @@ -481,48 +517,50 @@ def test_ssl_disabled_always_valid(self) -> None: assert error is None def test_ssl_enabled_missing_cert_path(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='true') + cfg = _make_config(SWML_SSL_ENABLED="true") cfg.ssl_cert_path = None - cfg.ssl_key_path = '/path/to/key.pem' + cfg.ssl_key_path = "/path/to/key.pem" is_valid, error = cfg.validate_ssl_config() assert is_valid is False assert error is not None - assert 'SWML_SSL_CERT_PATH' in error + assert "SWML_SSL_CERT_PATH" in error def test_ssl_enabled_missing_key_path(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='true') - cfg.ssl_cert_path = '/path/to/cert.pem' + cfg = _make_config(SWML_SSL_ENABLED="true") + cfg.ssl_cert_path = "/path/to/cert.pem" cfg.ssl_key_path = None is_valid, error = cfg.validate_ssl_config() assert is_valid is False assert error is not None - assert 'SWML_SSL_KEY_PATH' in error + assert "SWML_SSL_KEY_PATH" in error def test_ssl_enabled_cert_file_not_found(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='true') - cfg.ssl_cert_path = '/nonexistent/cert.pem' - cfg.ssl_key_path = '/nonexistent/key.pem' - with patch('os.path.exists', side_effect=lambda p: p != '/nonexistent/cert.pem'): + cfg = _make_config(SWML_SSL_ENABLED="true") + cfg.ssl_cert_path = "/nonexistent/cert.pem" + cfg.ssl_key_path = "/nonexistent/key.pem" + with patch( + "os.path.exists", side_effect=lambda p: p != "/nonexistent/cert.pem" + ): is_valid, error = cfg.validate_ssl_config() assert is_valid is False assert error is not None - assert 'certificate file not found' in error + assert "certificate file not found" in error def test_ssl_enabled_key_file_not_found(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='true') - cfg.ssl_cert_path = '/exists/cert.pem' - cfg.ssl_key_path = '/nonexistent/key.pem' - with patch('os.path.exists', side_effect=lambda p: p == '/exists/cert.pem'): + cfg = _make_config(SWML_SSL_ENABLED="true") + cfg.ssl_cert_path = "/exists/cert.pem" + cfg.ssl_key_path = "/nonexistent/key.pem" + with patch("os.path.exists", side_effect=lambda p: p == "/exists/cert.pem"): is_valid, error = cfg.validate_ssl_config() assert is_valid is False assert error is not None - assert 'key file not found' in error + assert "key file not found" in error def test_ssl_enabled_both_files_exist(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='true') - cfg.ssl_cert_path = '/exists/cert.pem' - cfg.ssl_key_path = '/exists/key.pem' - with patch('os.path.exists', return_value=True): + cfg = _make_config(SWML_SSL_ENABLED="true") + cfg.ssl_cert_path = "/exists/cert.pem" + cfg.ssl_key_path = "/exists/key.pem" + with patch("os.path.exists", return_value=True): is_valid, error = cfg.validate_ssl_config() assert is_valid is True assert error is None @@ -536,27 +574,27 @@ def test_ssl_disabled_returns_empty(self) -> None: assert cfg.get_ssl_context_kwargs() == {} def test_ssl_enabled_valid_returns_kwargs(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='true') - cfg.ssl_cert_path = '/exists/cert.pem' - cfg.ssl_key_path = '/exists/key.pem' - with patch('os.path.exists', return_value=True): + cfg = _make_config(SWML_SSL_ENABLED="true") + cfg.ssl_cert_path = "/exists/cert.pem" + cfg.ssl_key_path = "/exists/key.pem" + with patch("os.path.exists", return_value=True): result = cfg.get_ssl_context_kwargs() assert result == { - 'ssl_certfile': '/exists/cert.pem', - 'ssl_keyfile': '/exists/key.pem', + "ssl_certfile": "/exists/cert.pem", + "ssl_keyfile": "/exists/key.pem", } def test_ssl_enabled_invalid_returns_empty(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='true') + cfg = _make_config(SWML_SSL_ENABLED="true") cfg.ssl_cert_path = None cfg.ssl_key_path = None result = cfg.get_ssl_context_kwargs() assert result == {} def test_ssl_enabled_invalid_logs_error(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='true') + cfg = _make_config(SWML_SSL_ENABLED="true") cfg.ssl_cert_path = None - with patch('signalwire.core.security_config.logger') as mock_logger: + with patch("signalwire.core.security_config.logger") as mock_logger: cfg.get_ssl_context_kwargs() mock_logger.error.assert_called_once() @@ -567,12 +605,12 @@ class TestGetBasicAuth: def test_default_username(self) -> None: cfg = _make_config() username, _ = cfg.get_basic_auth() - assert username == 'signalwire' + assert username == "signalwire" def test_custom_username(self) -> None: - cfg = _make_config(SWML_BASIC_AUTH_USER='custom_user') + cfg = _make_config(SWML_BASIC_AUTH_USER="custom_user") username, _ = cfg.get_basic_auth() - assert username == 'custom_user' + assert username == "custom_user" def test_generates_password_when_not_set(self) -> None: cfg = _make_config() @@ -583,11 +621,13 @@ def test_generates_password_when_not_set(self) -> None: def test_password_is_url_safe_token(self) -> None: """Verify the generated password comes from secrets.token_urlsafe.""" cfg = _make_config() - with patch('signalwire.core.security_config.secrets.token_urlsafe', - return_value='mock_token_abc') as mock_token: + with patch( + "signalwire.core.security_config.secrets.token_urlsafe", + return_value="mock_token_abc", + ) as mock_token: _, password = cfg.get_basic_auth() mock_token.assert_called_once_with(32) - assert password == 'mock_token_abc' + assert password == "mock_token_abc" def test_password_caching_stability_same_instance(self) -> None: """Multiple calls to get_basic_auth on the same instance return the same password.""" @@ -601,37 +641,41 @@ def test_password_caching_stability_same_instance(self) -> None: def test_password_caching_does_not_regenerate(self) -> None: """After the first call generates a password, subsequent calls must not call secrets again.""" cfg = _make_config() - with patch('signalwire.core.security_config.secrets.token_urlsafe', - return_value='first_token') as mock_token: + with patch( + "signalwire.core.security_config.secrets.token_urlsafe", + return_value="first_token", + ) as mock_token: _, pw1 = cfg.get_basic_auth() - assert pw1 == 'first_token' + assert pw1 == "first_token" # Second call should NOT invoke token_urlsafe again - with patch('signalwire.core.security_config.secrets.token_urlsafe', - return_value='second_token') as mock_token: + with patch( + "signalwire.core.security_config.secrets.token_urlsafe", + return_value="second_token", + ) as mock_token: _, pw2 = cfg.get_basic_auth() mock_token.assert_not_called() - assert pw2 == 'first_token' + assert pw2 == "first_token" def test_preset_password_not_overwritten(self) -> None: - cfg = _make_config(SWML_BASIC_AUTH_PASSWORD='env_password') + cfg = _make_config(SWML_BASIC_AUTH_PASSWORD="env_password") _, password = cfg.get_basic_auth() - assert password == 'env_password' + assert password == "env_password" def test_preset_password_stability(self) -> None: """Pre-set password stays the same across calls.""" - cfg = _make_config(SWML_BASIC_AUTH_PASSWORD='stable') + cfg = _make_config(SWML_BASIC_AUTH_PASSWORD="stable") _, pw1 = cfg.get_basic_auth() _, pw2 = cfg.get_basic_auth() - assert pw1 == 'stable' - assert pw2 == 'stable' + assert pw1 == "stable" + assert pw2 == "stable" def test_externally_set_password_preserved(self) -> None: """Setting basic_auth_password directly is respected by get_basic_auth.""" cfg = _make_config() - cfg.basic_auth_password = 'manual_password' + cfg.basic_auth_password = "manual_password" _, password = cfg.get_basic_auth() - assert password == 'manual_password' + assert password == "manual_password" def test_returns_tuple(self) -> None: cfg = _make_config() @@ -657,47 +701,47 @@ class TestGetSecurityHeaders: def test_http_headers_no_hsts(self) -> None: cfg = _make_config() headers = cfg.get_security_headers(is_https=False) - assert 'X-Content-Type-Options' in headers - assert headers['X-Content-Type-Options'] == 'nosniff' - assert headers['X-Frame-Options'] == 'DENY' - assert headers['X-XSS-Protection'] == '1; mode=block' - assert headers['Referrer-Policy'] == 'strict-origin-when-cross-origin' - assert 'Strict-Transport-Security' not in headers + assert "X-Content-Type-Options" in headers + assert headers["X-Content-Type-Options"] == "nosniff" + assert headers["X-Frame-Options"] == "DENY" + assert headers["X-XSS-Protection"] == "1; mode=block" + assert headers["Referrer-Policy"] == "strict-origin-when-cross-origin" + assert "Strict-Transport-Security" not in headers def test_https_with_hsts_enabled(self) -> None: cfg = _make_config() headers = cfg.get_security_headers(is_https=True) - assert 'Strict-Transport-Security' in headers - assert '31536000' in headers['Strict-Transport-Security'] - assert 'includeSubDomains' in headers['Strict-Transport-Security'] + assert "Strict-Transport-Security" in headers + assert "31536000" in headers["Strict-Transport-Security"] + assert "includeSubDomains" in headers["Strict-Transport-Security"] def test_https_with_hsts_disabled(self) -> None: cfg = _make_config() cfg.use_hsts = False headers = cfg.get_security_headers(is_https=True) - assert 'Strict-Transport-Security' not in headers + assert "Strict-Transport-Security" not in headers def test_https_custom_hsts_max_age(self) -> None: cfg = _make_config() cfg.hsts_max_age = 86400 headers = cfg.get_security_headers(is_https=True) - assert 'max-age=86400' in headers['Strict-Transport-Security'] + assert "max-age=86400" in headers["Strict-Transport-Security"] def test_default_is_http(self) -> None: """Default is_https=False.""" cfg = _make_config() headers = cfg.get_security_headers() - assert 'Strict-Transport-Security' not in headers + assert "Strict-Transport-Security" not in headers def test_always_includes_base_headers(self) -> None: """Base security headers are always present regardless of HTTPS status.""" cfg = _make_config() for is_https in (True, False): headers = cfg.get_security_headers(is_https=is_https) - assert 'X-Content-Type-Options' in headers - assert 'X-Frame-Options' in headers - assert 'X-XSS-Protection' in headers - assert 'Referrer-Policy' in headers + assert "X-Content-Type-Options" in headers + assert "X-Frame-Options" in headers + assert "X-XSS-Protection" in headers + assert "Referrer-Policy" in headers class TestShouldAllowHost: @@ -705,28 +749,28 @@ class TestShouldAllowHost: def test_wildcard_allows_all(self) -> None: cfg = _make_config() - assert cfg.should_allow_host('anything.com') is True - assert cfg.should_allow_host('') is True - assert cfg.should_allow_host('localhost') is True + assert cfg.should_allow_host("anything.com") is True + assert cfg.should_allow_host("") is True + assert cfg.should_allow_host("localhost") is True def test_specific_host_allowed(self) -> None: - cfg = _make_config(SWML_ALLOWED_HOSTS='example.com,api.example.com') - assert cfg.should_allow_host('example.com') is True - assert cfg.should_allow_host('api.example.com') is True + cfg = _make_config(SWML_ALLOWED_HOSTS="example.com,api.example.com") + assert cfg.should_allow_host("example.com") is True + assert cfg.should_allow_host("api.example.com") is True def test_host_not_in_list(self) -> None: - cfg = _make_config(SWML_ALLOWED_HOSTS='example.com') - assert cfg.should_allow_host('other.com') is False + cfg = _make_config(SWML_ALLOWED_HOSTS="example.com") + assert cfg.should_allow_host("other.com") is False def test_empty_host_not_allowed_when_specific(self) -> None: - cfg = _make_config(SWML_ALLOWED_HOSTS='example.com') - assert cfg.should_allow_host('') is False + cfg = _make_config(SWML_ALLOWED_HOSTS="example.com") + assert cfg.should_allow_host("") is False def test_case_sensitive_matching(self) -> None: """Host matching is case-sensitive (as set by the environment).""" - cfg = _make_config(SWML_ALLOWED_HOSTS='Example.com') - assert cfg.should_allow_host('Example.com') is True - assert cfg.should_allow_host('example.com') is False + cfg = _make_config(SWML_ALLOWED_HOSTS="Example.com") + assert cfg.should_allow_host("Example.com") is True + assert cfg.should_allow_host("example.com") is False class TestGetCorsConfig: @@ -735,20 +779,25 @@ class TestGetCorsConfig: def test_default_cors_config(self) -> None: cfg = _make_config() cors = cfg.get_cors_config() - assert cors['allow_origins'] == ['*'] - assert cors['allow_credentials'] is True - assert cors['allow_methods'] == ['*'] - assert cors['allow_headers'] == ['*'] + assert cors["allow_origins"] == ["*"] + assert cors["allow_credentials"] is True + assert cors["allow_methods"] == ["*"] + assert cors["allow_headers"] == ["*"] def test_custom_cors_origins(self) -> None: - cfg = _make_config(SWML_CORS_ORIGINS='http://localhost:3000,http://app.com') + cfg = _make_config(SWML_CORS_ORIGINS="http://localhost:3000,http://app.com") cors = cfg.get_cors_config() - assert cors['allow_origins'] == ['http://localhost:3000', 'http://app.com'] + assert cors["allow_origins"] == ["http://localhost:3000", "http://app.com"] def test_cors_config_keys(self) -> None: cfg = _make_config() cors = cfg.get_cors_config() - assert set(cors.keys()) == {'allow_origins', 'allow_credentials', 'allow_methods', 'allow_headers'} + assert set(cors.keys()) == { + "allow_origins", + "allow_credentials", + "allow_methods", + "allow_headers", + } class TestGetUrlScheme: @@ -756,11 +805,11 @@ class TestGetUrlScheme: def test_http_when_ssl_disabled(self) -> None: cfg = _make_config() - assert cfg.get_url_scheme() == 'http' + assert cfg.get_url_scheme() == "http" def test_https_when_ssl_enabled(self) -> None: - cfg = _make_config(SWML_SSL_ENABLED='true') - assert cfg.get_url_scheme() == 'https' + cfg = _make_config(SWML_SSL_ENABLED="true") + assert cfg.get_url_scheme() == "https" class TestLogConfig: @@ -768,50 +817,50 @@ class TestLogConfig: def test_log_config_calls_logger(self) -> None: cfg = _make_config() - with patch('signalwire.core.security_config.logger') as mock_logger: - cfg.log_config('test_service') + with patch("signalwire.core.security_config.logger") as mock_logger: + cfg.log_config("test_service") mock_logger.info.assert_called_once() def test_log_config_includes_service_name(self) -> None: cfg = _make_config() - with patch('signalwire.core.security_config.logger') as mock_logger: - cfg.log_config('my_service') + with patch("signalwire.core.security_config.logger") as mock_logger: + cfg.log_config("my_service") call_kwargs = mock_logger.info.call_args # The first positional arg is the event name - assert call_kwargs[0][0] == 'security_config_loaded' + assert call_kwargs[0][0] == "security_config_loaded" # Keyword args should include service - assert call_kwargs[1]['service'] == 'my_service' + assert call_kwargs[1]["service"] == "my_service" def test_log_config_includes_key_fields(self) -> None: cfg = _make_config() - with patch('signalwire.core.security_config.logger') as mock_logger: - cfg.log_config('svc') + with patch("signalwire.core.security_config.logger") as mock_logger: + cfg.log_config("svc") kwargs = mock_logger.info.call_args[1] - assert 'ssl_enabled' in kwargs - assert 'domain' in kwargs - assert 'allowed_hosts' in kwargs - assert 'cors_origins' in kwargs - assert 'max_request_size' in kwargs - assert 'rate_limit' in kwargs - assert 'use_hsts' in kwargs - assert 'has_basic_auth' in kwargs + assert "ssl_enabled" in kwargs + assert "domain" in kwargs + assert "allowed_hosts" in kwargs + assert "cors_origins" in kwargs + assert "max_request_size" in kwargs + assert "rate_limit" in kwargs + assert "use_hsts" in kwargs + assert "has_basic_auth" in kwargs def test_log_config_has_basic_auth_true(self) -> None: cfg = _make_config( - SWML_BASIC_AUTH_USER='user', - SWML_BASIC_AUTH_PASSWORD='pass', + SWML_BASIC_AUTH_USER="user", + SWML_BASIC_AUTH_PASSWORD="pass", ) - with patch('signalwire.core.security_config.logger') as mock_logger: - cfg.log_config('svc') + with patch("signalwire.core.security_config.logger") as mock_logger: + cfg.log_config("svc") kwargs = mock_logger.info.call_args[1] - assert kwargs['has_basic_auth'] is True + assert kwargs["has_basic_auth"] is True def test_log_config_has_basic_auth_false(self) -> None: cfg = _make_config() - with patch('signalwire.core.security_config.logger') as mock_logger: - cfg.log_config('svc') + with patch("signalwire.core.security_config.logger") as mock_logger: + cfg.log_config("svc") kwargs = mock_logger.info.call_args[1] - assert kwargs['has_basic_auth'] is False + assert kwargs["has_basic_auth"] is False class TestGlobalInstance: @@ -820,11 +869,13 @@ class TestGlobalInstance: def test_global_instance_exists(self) -> None: from signalwire.core.security_config import security_config from signalwire.core.security_config import SecurityConfig + assert isinstance(security_config, SecurityConfig) def test_global_instance_is_same_on_reimport(self) -> None: from signalwire.core.security_config import security_config as sc1 from signalwire.core.security_config import security_config as sc2 + assert sc1 is sc2 @@ -839,24 +890,27 @@ def test_defaults_then_env_then_config_file(self) -> None: mock_config_loader = MagicMock() mock_config_loader.has_config.return_value = True mock_config_loader.get_section.return_value = { - 'rate_limit': '300', + "rate_limit": "300", } clean = {k: v for k, v in os.environ.items() if k not in ENV_CLEAR_KEYS} # Env sets rate_limit to 100 - clean['SWML_RATE_LIMIT'] = '100' - - with patch.dict(os.environ, clean, clear=True): - with patch( - 'signalwire.core.security_config.ConfigLoader.find_config_file', - return_value='/fake/config.json', - ): - with patch( - 'signalwire.core.security_config.ConfigLoader', - return_value=mock_config_loader, - ): - from signalwire.core.security_config import SecurityConfig - cfg = SecurityConfig() + clean["SWML_RATE_LIMIT"] = "100" + + with ( + patch.dict(os.environ, clean, clear=True), + patch( + "signalwire.core.security_config.ConfigLoader.find_config_file", + return_value="/fake/config.json", + ), + patch( + "signalwire.core.security_config.ConfigLoader", + return_value=mock_config_loader, + ), + ): + from signalwire.core.security_config import SecurityConfig + + cfg = SecurityConfig() # Config file value (300) should win over env (100) and default (60) assert cfg.rate_limit == 300 @@ -866,90 +920,91 @@ class TestEdgeCases: """Test edge cases and boundary conditions.""" def test_zero_rate_limit(self) -> None: - cfg = _make_config(SWML_RATE_LIMIT='0') + cfg = _make_config(SWML_RATE_LIMIT="0") assert cfg.rate_limit == 0 def test_zero_request_timeout(self) -> None: - cfg = _make_config(SWML_REQUEST_TIMEOUT='0') + cfg = _make_config(SWML_REQUEST_TIMEOUT="0") assert cfg.request_timeout == 0 def test_zero_max_request_size(self) -> None: - cfg = _make_config(SWML_MAX_REQUEST_SIZE='0') + cfg = _make_config(SWML_MAX_REQUEST_SIZE="0") assert cfg.max_request_size == 0 def test_zero_hsts_max_age(self) -> None: - cfg = _make_config(SWML_HSTS_MAX_AGE='0') + cfg = _make_config(SWML_HSTS_MAX_AGE="0") assert cfg.hsts_max_age == 0 headers = cfg.get_security_headers(is_https=True) - assert 'max-age=0' in headers['Strict-Transport-Security'] + assert "max-age=0" in headers["Strict-Transport-Security"] def test_very_large_max_request_size(self) -> None: - cfg = _make_config(SWML_MAX_REQUEST_SIZE='1073741824') # 1GB + cfg = _make_config(SWML_MAX_REQUEST_SIZE="1073741824") # 1GB assert cfg.max_request_size == 1073741824 def test_ssl_validate_after_manual_state_change(self) -> None: """Validate SSL after manually changing attributes.""" cfg = _make_config() cfg.ssl_enabled = True - cfg.ssl_cert_path = '/some/cert.pem' - cfg.ssl_key_path = '/some/key.pem' - with patch('os.path.exists', return_value=True): + cfg.ssl_cert_path = "/some/cert.pem" + cfg.ssl_key_path = "/some/key.pem" + with patch("os.path.exists", return_value=True): is_valid, error = cfg.validate_ssl_config() assert is_valid is True + assert error is None def test_allowed_hosts_single_entry(self) -> None: - cfg = _make_config(SWML_ALLOWED_HOSTS='only-this-host.com') - assert cfg.allowed_hosts == ['only-this-host.com'] - assert cfg.should_allow_host('only-this-host.com') is True - assert cfg.should_allow_host('other.com') is False + cfg = _make_config(SWML_ALLOWED_HOSTS="only-this-host.com") + assert cfg.allowed_hosts == ["only-this-host.com"] + assert cfg.should_allow_host("only-this-host.com") is True + assert cfg.should_allow_host("other.com") is False def test_parse_list_with_whitespace_only_items(self) -> None: cfg = _make_config() - result = cfg._parse_list('a, , b, ,c') - assert result == ['a', 'b', 'c'] + result = cfg._parse_list("a, , b, ,c") + assert result == ["a", "b", "c"] def test_get_basic_auth_empty_string_user(self) -> None: """Empty string user from env should be treated as falsy, defaulting to 'signalwire'.""" cfg = _make_config() - cfg.basic_auth_user = '' + cfg.basic_auth_user = "" username, _ = cfg.get_basic_auth() - assert username == 'signalwire' + assert username == "signalwire" def test_get_basic_auth_empty_string_password_generates_new(self) -> None: """Empty string password should be treated as falsy, generating a new one.""" cfg = _make_config() - cfg.basic_auth_password = '' + cfg.basic_auth_password = "" _, password = cfg.get_basic_auth() assert len(password) > 0 - assert password != '' + assert password != "" def test_multiple_env_vars_combined(self) -> None: """Test setting many environment variables simultaneously.""" cfg = _make_config( - SWML_SSL_ENABLED='true', - SWML_SSL_CERT_PATH='/cert', - SWML_SSL_KEY_PATH='/key', - SWML_DOMAIN='multi.com', - SWML_ALLOWED_HOSTS='h1.com,h2.com', - SWML_CORS_ORIGINS='http://c1.com', - SWML_MAX_REQUEST_SIZE='999', - SWML_RATE_LIMIT='10', - SWML_REQUEST_TIMEOUT='5', - SWML_USE_HSTS='false', - SWML_HSTS_MAX_AGE='100', - SWML_BASIC_AUTH_USER='admin', - SWML_BASIC_AUTH_PASSWORD='pass', + SWML_SSL_ENABLED="true", + SWML_SSL_CERT_PATH="/cert", + SWML_SSL_KEY_PATH="/key", + SWML_DOMAIN="multi.com", + SWML_ALLOWED_HOSTS="h1.com,h2.com", + SWML_CORS_ORIGINS="http://c1.com", + SWML_MAX_REQUEST_SIZE="999", + SWML_RATE_LIMIT="10", + SWML_REQUEST_TIMEOUT="5", + SWML_USE_HSTS="false", + SWML_HSTS_MAX_AGE="100", + SWML_BASIC_AUTH_USER="admin", + SWML_BASIC_AUTH_PASSWORD="pass", ) assert cfg.ssl_enabled is True - assert cfg.ssl_cert_path == '/cert' - assert cfg.ssl_key_path == '/key' - assert cfg.domain == 'multi.com' - assert cfg.allowed_hosts == ['h1.com', 'h2.com'] - assert cfg.cors_origins == ['http://c1.com'] + assert cfg.ssl_cert_path == "/cert" + assert cfg.ssl_key_path == "/key" + assert cfg.domain == "multi.com" + assert cfg.allowed_hosts == ["h1.com", "h2.com"] + assert cfg.cors_origins == ["http://c1.com"] assert cfg.max_request_size == 999 assert cfg.rate_limit == 10 assert cfg.request_timeout == 5 assert cfg.use_hsts is False assert cfg.hsts_max_age == 100 - assert cfg.basic_auth_user == 'admin' - assert cfg.basic_auth_password == 'pass' + assert cfg.basic_auth_user == "admin" + assert cfg.basic_auth_password == "pass" diff --git a/tests/unit/core/test_serverless_secure_token.py b/tests/unit/core/test_serverless_secure_token.py new file mode 100644 index 00000000..f33783b1 --- /dev/null +++ b/tests/unit/core/test_serverless_secure_token.py @@ -0,0 +1,382 @@ +""" +Copyright (c) 2025 SignalWire + +This file is part of the SignalWire SDK. + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. +""" + +""" +Serverless `secure=True` token enforcement. + +A tool registered with ``secure=True`` must be enforced on EVERY transport, not +just HTTP. These tests pin the contract across all four serverless modes +(lambda, cgi, google_cloud_function, azure_function) for the four token states: + + * valid -> the handler RUNS (a fix that refuses everything is not a fix) + * forged -> refused + * absent -> refused (omitting a credential is never weaker than a wrong one) + * no call_id -> refused (a token can only be validated against a call_id) + +An ``secure=False`` tool proceeds ungated in every state. + +The refusal shape is a 200 + FunctionResult body, NOT an HTTP error status -- +the engine (mod_openai) has no handling for a SWAIG refusal status, so the tool +reports that it cannot execute and the model relays it. +""" + +import base64 +import io +import json +import sys +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from signalwire.core.agent_base import AgentBase +from signalwire.core.function_result import FunctionResult + +HANDLER_RAN = "HANDLER RAN" +REFUSAL_FRAGMENT = "security token for this function is invalid" + +_USER = "u" +_PASS = "p" +_BASIC = "Basic " + base64.b64encode(f"{_USER}:{_PASS}".encode()).decode() + +CALL_ID = "test-call-id" + + +class _SecureAgent(AgentBase): + """Agent exposing one secure tool and one insecure tool.""" + + def __init__(self) -> None: + super().__init__( + name="secure-agent", + route="/", + basic_auth=(_USER, _PASS), + ) + self.define_tool("secret_tool", "secure", {}, self._handler, secure=True) + self.define_tool("open_tool", "insecure", {}, self._handler, secure=False) + + def _handler(self, args: dict[str, Any], raw_data: Any) -> FunctionResult: + return FunctionResult(HANDLER_RAN) + + +@pytest.fixture +def agent() -> _SecureAgent: + return _SecureAgent() + + +def _valid_token( + agent: _SecureAgent, function_name: str, call_id: str = CALL_ID +) -> str: + return str(agent._session_manager.generate_token(function_name, call_id)) + + +def _swaig_body(function_name: str, call_id: str | None = CALL_ID) -> dict[str, Any]: + body: dict[str, Any] = { + "function": function_name, + "argument": {"parsed": [{}], "raw": "{}"}, + } + if call_id is not None: + body["call_id"] = call_id + return body + + +def _assert_ran(payload: dict[str, Any]) -> None: + assert payload.get("response") == HANDLER_RAN, ( + f"expected the handler to RUN, got {payload!r}" + ) + + +def _assert_refused(payload: dict[str, Any]) -> None: + response = payload.get("response", "") + assert REFUSAL_FRAGMENT in response, ( + f"expected a secure-token REFUSAL, got {payload!r}" + ) + assert HANDLER_RAN not in json.dumps(payload), ( + f"handler RAN despite an invalid/absent token: {payload!r}" + ) + + +# --------------------------------------------------------------------------- +# Per-mode invocation helpers -- each returns the decoded SWAIG result dict. +# +# The four modes carry the query string in four DIFFERENT places; that is the +# whole point of the per-mode extraction under test. +# --------------------------------------------------------------------------- + + +def _invoke_lambda_v2( + agent: _SecureAgent, + function_name: str, + token: str | None, + call_id: str | None = CALL_ID, +) -> dict[str, Any]: + """HTTP API v2 payload: `rawPath` + `queryStringParameters` dict.""" + event: dict[str, Any] = { + "rawPath": f"/{function_name}", + "headers": {"Authorization": _BASIC}, + "body": json.dumps(_swaig_body(function_name, call_id)), + } + if token is not None: + event["queryStringParameters"] = {"__token": token} + result = agent.handle_serverless_request(event=event, mode="lambda") + assert result["statusCode"] == 200, ( + f"refusal must be a 200 + FunctionResult body, got {result['statusCode']}" + ) + return dict(json.loads(result["body"])) + + +def _invoke_lambda_v2_raw( + agent: _SecureAgent, + function_name: str, + token: str | None, + call_id: str | None = CALL_ID, +) -> dict[str, Any]: + """HTTP API v2 payload variant carrying `rawQueryString` instead of the dict.""" + event: dict[str, Any] = { + "rawPath": f"/{function_name}", + "headers": {"Authorization": _BASIC}, + "body": json.dumps(_swaig_body(function_name, call_id)), + } + if token is not None: + event["rawQueryString"] = f"__token={token}" + result = agent.handle_serverless_request(event=event, mode="lambda") + assert result["statusCode"] == 200 + return dict(json.loads(result["body"])) + + +def _invoke_lambda_v1( + agent: _SecureAgent, + function_name: str, + token: str | None, + call_id: str | None = CALL_ID, +) -> dict[str, Any]: + """REST API v1 payload: `pathParameters.proxy` + `queryStringParameters`.""" + event: dict[str, Any] = { + "pathParameters": {"proxy": function_name}, + "headers": {"Authorization": _BASIC}, + "body": json.dumps(_swaig_body(function_name, call_id)), + } + if token is not None: + event["queryStringParameters"] = {"__token": token} + result = agent.handle_serverless_request(event=event, mode="lambda") + assert result["statusCode"] == 200 + return dict(json.loads(result["body"])) + + +def _invoke_cgi( + agent: _SecureAgent, + function_name: str, + token: str | None, + call_id: str | None = CALL_ID, +) -> dict[str, Any]: + """CGI: `QUERY_STRING` environment variable.""" + body = json.dumps(_swaig_body(function_name, call_id)) + env = { + "PATH_INFO": f"/{function_name}", + "CONTENT_LENGTH": str(len(body)), + "HTTP_AUTHORIZATION": _BASIC, + "QUERY_STRING": f"__token={token}" if token is not None else "", + } + with ( + patch.dict("os.environ", env, clear=False), + patch.object(sys, "stdin", io.StringIO(body)), + ): + result = agent.handle_serverless_request(mode="cgi") + return dict(result) + + +def _invoke_gcf( + agent: _SecureAgent, + function_name: str, + token: str | None, + call_id: str | None = CALL_ID, +) -> dict[str, Any]: + """Google Cloud Functions: Flask `request.args` mapping.""" + request = Mock() + request.path = f"/{function_name}" + request.method = "POST" + request.url = f"https://region-proj.cloudfunctions.net/{function_name}" + request.headers = {"Authorization": _BASIC} + request.args = {"__token": token} if token is not None else {} + request.query_string = f"__token={token}".encode() if token is not None else b"" + payload = _swaig_body(function_name, call_id) + request.is_json = True + request.get_json = Mock(return_value=payload) + request.get_data = Mock(return_value=json.dumps(payload).encode()) + + captured: dict[str, Any] = {} + + class _Response: + def __init__(self, response: str, status: int, headers: Any = None) -> None: + captured["body"] = response + captured["status"] = status + + flask_stub = Mock() + flask_stub.Response = _Response + with patch.dict(sys.modules, {"flask": flask_stub}): + agent.handle_serverless_request(event=request, mode="google_cloud_function") + + assert captured["status"] == 200, ( + f"refusal must be a 200 + FunctionResult body, got {captured['status']}" + ) + return dict(json.loads(captured["body"])) + + +def _invoke_azure( + agent: _SecureAgent, + function_name: str, + token: str | None, + call_id: str | None = CALL_ID, +) -> dict[str, Any]: + """Azure Functions: `req.params` mapping (and the query in `req.url`).""" + query = f"?__token={token}" if token is not None else "" + req = Mock() + req.url = f"https://app.azurewebsites.net/api/myagent/{function_name}{query}" + req.method = "POST" + req.headers = {"Authorization": _BASIC} + req.params = {"__token": token} if token is not None else {} + req.get_body = Mock( + return_value=json.dumps(_swaig_body(function_name, call_id)).encode() + ) + + captured: dict[str, Any] = {} + + class _HttpResponse: + def __init__( + self, body: str, status_code: int = 200, headers: Any = None + ) -> None: + captured["body"] = body + captured["status"] = status_code + + func_stub = Mock() + func_stub.HttpResponse = _HttpResponse + with patch.dict( + sys.modules, + {"azure": Mock(functions=func_stub), "azure.functions": func_stub}, + ): + agent.handle_serverless_request(event=req, mode="azure_function") + + assert captured["status"] == 200, ( + f"refusal must be a 200 + FunctionResult body, got {captured['status']}" + ) + return dict(json.loads(captured["body"])) + + +_INVOKERS = { + "lambda_v2": _invoke_lambda_v2, + "lambda_v2_rawquery": _invoke_lambda_v2_raw, + "lambda_v1": _invoke_lambda_v1, + "cgi": _invoke_cgi, + "google_cloud_function": _invoke_gcf, + "azure_function": _invoke_azure, +} + +MODES = list(_INVOKERS) + + +# --------------------------------------------------------------------------- +# The 4-state matrix, per mode. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("mode", MODES) +def test_secure_tool_valid_token_runs(agent: _SecureAgent, mode: str) -> None: + """A VALID token must still run the handler in every serverless mode.""" + token = _valid_token(agent, "secret_tool") + _assert_ran(_INVOKERS[mode](agent, "secret_tool", token)) + + +@pytest.mark.parametrize("mode", MODES) +def test_secure_tool_forged_token_refused(agent: _SecureAgent, mode: str) -> None: + """A FORGED token must be refused in every serverless mode.""" + _assert_refused( + _INVOKERS[mode](agent, "secret_tool", "obviously-not-a-valid-token") + ) + + +@pytest.mark.parametrize("mode", MODES) +def test_secure_tool_absent_token_refused(agent: _SecureAgent, mode: str) -> None: + """An ABSENT token must be refused -- never weaker than a wrong one.""" + _assert_refused(_INVOKERS[mode](agent, "secret_tool", None)) + + +@pytest.mark.parametrize("mode", MODES) +def test_secure_tool_missing_call_id_refused(agent: _SecureAgent, mode: str) -> None: + """A token with NO call_id to check it against counts as UNVALIDATED.""" + token = _valid_token(agent, "secret_tool") + _assert_refused(_INVOKERS[mode](agent, "secret_tool", token, call_id=None)) + + +@pytest.mark.parametrize("mode", MODES) +def test_secure_tool_token_for_other_function_refused( + agent: _SecureAgent, mode: str +) -> None: + """A token minted for a DIFFERENT function must not authorize this one.""" + token = _valid_token(agent, "open_tool") + _assert_refused(_INVOKERS[mode](agent, "secret_tool", token)) + + +@pytest.mark.parametrize("mode", MODES) +def test_secure_tool_token_for_other_call_refused( + agent: _SecureAgent, mode: str +) -> None: + """A token minted for a DIFFERENT call_id must not authorize this call.""" + token = _valid_token(agent, "secret_tool", call_id="some-other-call") + _assert_refused(_INVOKERS[mode](agent, "secret_tool", token)) + + +# --------------------------------------------------------------------------- +# An insecure tool proceeds ungated in every state -- the fix must not make +# `secure=False` behave like `secure=True`. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("mode", MODES) +def test_insecure_tool_absent_token_runs(agent: _SecureAgent, mode: str) -> None: + _assert_ran(_INVOKERS[mode](agent, "open_tool", None)) + + +@pytest.mark.parametrize("mode", MODES) +def test_insecure_tool_forged_token_runs(agent: _SecureAgent, mode: str) -> None: + _assert_ran(_INVOKERS[mode](agent, "open_tool", "garbage-token")) + + +@pytest.mark.parametrize("mode", MODES) +def test_insecure_tool_missing_call_id_runs(agent: _SecureAgent, mode: str) -> None: + _assert_ran(_INVOKERS[mode](agent, "open_tool", None, call_id=None)) + + +# --------------------------------------------------------------------------- +# The `token` fallback spelling is honoured exactly as HTTP does. +# --------------------------------------------------------------------------- + + +def test_lambda_bare_token_param_accepted(agent: _SecureAgent) -> None: + """HTTP reads `__token` then falls back to `token`; serverless matches.""" + token = _valid_token(agent, "secret_tool") + event = { + "rawPath": "/secret_tool", + "headers": {"Authorization": _BASIC}, + "queryStringParameters": {"token": token}, + "body": json.dumps(_swaig_body("secret_tool")), + } + result = agent.handle_serverless_request(event=event, mode="lambda") + _assert_ran(json.loads(result["body"])) + + +def test_lambda_dunder_token_wins_over_bare(agent: _SecureAgent) -> None: + """`__token` takes precedence over `token`, matching the HTTP path.""" + token = _valid_token(agent, "secret_tool") + event = { + "rawPath": "/secret_tool", + "headers": {"Authorization": _BASIC}, + "queryStringParameters": {"__token": token, "token": "garbage"}, + "body": json.dumps(_swaig_body("secret_tool")), + } + result = agent.handle_serverless_request(event=event, mode="lambda") + _assert_ran(json.loads(result["body"])) diff --git a/tests/unit/core/test_session_manager.py b/tests/unit/core/test_session_manager.py index 18a6bf4e..0fa2c444 100644 --- a/tests/unit/core/test_session_manager.py +++ b/tests/unit/core/test_session_manager.py @@ -13,62 +13,58 @@ import pytest import time -import hmac -import hashlib import base64 -from datetime import datetime, timedelta -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import patch from signalwire.core.security.session_manager import SessionManager class TestSessionManager: """Test SessionManager functionality""" - + def test_basic_initialization(self) -> None: """Test basic SessionManager initialization""" manager = SessionManager() - + assert manager.secret_key is not None assert len(manager.secret_key) >= 32 # Should be secure length assert manager.token_expiry_secs == 900 # Default 15 minutes - + def test_initialization_with_custom_params(self) -> None: """Test initialization with custom parameters""" custom_secret = "my_custom_secret_key_that_is_long_enough" custom_expiry = 7200 # 2 hours - + manager = SessionManager( - secret_key=custom_secret, - token_expiry_secs=custom_expiry + secret_key=custom_secret, token_expiry_secs=custom_expiry ) - + assert manager.secret_key == custom_secret assert manager.token_expiry_secs == custom_expiry - + def test_create_session(self) -> None: """Test session creation""" manager = SessionManager() - + # Test with provided call_id call_id = manager.create_session("test_call_123") assert call_id == "test_call_123" - + # Test with auto-generated call_id auto_call_id = manager.create_session() assert auto_call_id is not None assert len(auto_call_id) > 0 assert isinstance(auto_call_id, str) - + def test_generate_token_basic(self) -> None: """Test basic token generation""" manager = SessionManager() - + token = manager.generate_token("test_function", "call_123") - + assert isinstance(token, str) assert len(token) > 0 - + # Should be base64 encoded try: decoded = base64.urlsafe_b64decode(token.encode()).decode() @@ -76,105 +72,105 @@ def test_generate_token_basic(self) -> None: assert "test_function" in decoded except Exception: pytest.fail("Token should be valid base64") - + def test_create_tool_token_alias(self) -> None: """Test create_tool_token alias""" manager = SessionManager() - + token1 = manager.generate_token("test_func", "call_123") token2 = manager.create_tool_token("test_func", "call_123") - + # Should both be valid tokens (though different due to nonce) assert isinstance(token1, str) assert isinstance(token2, str) assert len(token1) > 0 assert len(token2) > 0 - + def test_validate_token_valid(self) -> None: """Test validating valid token""" manager = SessionManager() - + token = manager.generate_token("test_function", "call_123") - + # Should be valid immediately assert manager.validate_token("call_123", "test_function", token) is True - + def test_validate_token_wrong_function(self) -> None: """Test validating token with wrong function name""" manager = SessionManager() - + token = manager.generate_token("test_function", "call_123") - + # Should be invalid for different function assert manager.validate_token("call_123", "other_function", token) is False - + def test_validate_token_wrong_call_id(self) -> None: """Test validating token with wrong call ID""" manager = SessionManager() - + token = manager.generate_token("test_function", "call_123") - + # Should be invalid for different call_id assert manager.validate_token("other_call", "test_function", token) is False - + def test_validate_token_expired(self) -> None: """Test validating expired token""" manager = SessionManager(token_expiry_secs=1) # 1 second expiry - + token = manager.generate_token("test_function", "call_123") - + # Wait for token to expire time.sleep(2) - + # Should be invalid due to expiry assert manager.validate_token("call_123", "test_function", token) is False - + def test_validate_token_invalid_signature(self) -> None: """Test validating token with invalid signature""" manager1 = SessionManager(secret_key="secret1" + "x" * 24) manager2 = SessionManager(secret_key="secret2" + "x" * 24) - + token = manager1.generate_token("test_function", "call_123") - + # Should be invalid with different secret assert manager2.validate_token("call_123", "test_function", token) is False - + def test_validate_token_malformed(self) -> None: """Test validating malformed token""" manager = SessionManager() - + # Test various malformed tokens malformed_tokens = [ "not_base64", "invalid_token", "", base64.urlsafe_b64encode(b"too.few.parts").decode(), - base64.urlsafe_b64encode(b"too.many.parts.here.extra.stuff").decode() + base64.urlsafe_b64encode(b"too.many.parts.here.extra.stuff").decode(), ] - + for token in malformed_tokens: assert manager.validate_token("call_123", "test_function", token) is False - + def test_validate_token_empty_call_id(self) -> None: """Test validating token with empty call_id (special case)""" manager = SessionManager() - + token = manager.generate_token("test_function", "call_123") - + # Should reject with empty call_id (no longer falls back to token's call_id) assert manager.validate_token("", "test_function", token) is False assert manager.validate_token(None, "test_function", token) is False # type: ignore[arg-type] # intentional invalid input for validation test - + def test_validate_tool_token_alias(self) -> None: """Test validate_tool_token alias""" manager = SessionManager() - + token = manager.generate_token("test_function", "call_123") - + # Test alias method (note parameter order difference) assert manager.validate_tool_token("test_function", token, "call_123") is True assert manager.validate_tool_token("wrong_function", token, "call_123") is False - + def test_debug_token(self) -> None: """Test token debugging functionality""" manager = SessionManager() @@ -182,12 +178,12 @@ def test_debug_token(self) -> None: token = manager.generate_token("test_function", "call_123") debug_info = manager.debug_token(token) - + assert isinstance(debug_info, dict) assert "components" in debug_info assert "status" in debug_info assert "valid_format" in debug_info - + # Check components components = debug_info["components"] assert components["call_id"] == "call_123" @@ -195,203 +191,213 @@ def test_debug_token(self) -> None: assert isinstance(components["expiry"], str) assert isinstance(components["nonce"], str) assert isinstance(components["signature"], str) - + # Check status status = debug_info["status"] assert isinstance(status["current_time"], int) assert isinstance(status["expires_in_seconds"], int) assert isinstance(status["is_expired"], bool) - + def test_debug_token_invalid(self) -> None: """Test debugging invalid token""" manager = SessionManager() manager._debug_mode = True debug_info = manager.debug_token("invalid_token") - + assert debug_info is not None assert "error" in debug_info assert "valid_format" in debug_info assert debug_info["valid_format"] is False - + def test_legacy_methods(self) -> None: """Test legacy API compatibility methods""" manager = SessionManager() - + # These should all work but not do anything meaningful assert manager.activate_session("call_123") is True assert manager.end_session("call_123") is True - + metadata = manager.get_session_metadata("call_123") assert isinstance(metadata, dict) assert len(metadata) == 0 - + assert manager.set_session_metadata("call_123", "key", "value") is True class TestSessionManagerErrorHandling: """Test error handling in SessionManager""" - + def test_token_generation_edge_cases(self) -> None: """Test token generation with edge cases""" manager = SessionManager() - + # Empty function name token = manager.generate_token("", "call_123") assert isinstance(token, str) assert manager.validate_token("call_123", "", token) is True - + # Empty call_id - validation should now reject empty call_id token = manager.generate_token("test_func", "") assert isinstance(token, str) assert manager.validate_token("", "test_func", token) is False - + def test_validation_with_corrupted_token(self) -> None: """Test validation with corrupted token data""" manager = SessionManager() - + # Create valid token then corrupt it token = manager.generate_token("test_function", "call_123") - + # Corrupt the base64 data corrupted = token[:-5] + "XXXXX" assert manager.validate_token("call_123", "test_function", corrupted) is False - + def test_time_manipulation_resistance(self) -> None: """Test resistance to time manipulation attacks""" manager = SessionManager(token_expiry_secs=3600) - + # Generate token token = manager.generate_token("test_function", "call_123") - + # Mock time to be in the future (simulating clock skew) - with patch('time.time', return_value=time.time() + 7200): # 2 hours ahead + with patch("time.time", return_value=time.time() + 7200): # 2 hours ahead # Token should be expired assert manager.validate_token("call_123", "test_function", token) is False class TestSessionManagerIntegration: """Test integration scenarios""" - + def test_complete_token_workflow(self) -> None: """Test complete token management workflow""" manager = SessionManager() manager._debug_mode = True - + # 1. Create session call_id = manager.create_session() assert call_id is not None - + # 2. Generate token for function token = manager.generate_token("get_balance", call_id) assert token is not None - + # 3. Validate token assert manager.validate_token(call_id, "get_balance", token) is True - + # 4. Debug token debug_info = manager.debug_token(token) # call_id may be truncated in debug output ([:8] + "..." for long IDs) - assert call_id.startswith(debug_info["components"]["call_id"].replace("...", "")) + assert call_id.startswith( + debug_info["components"]["call_id"].replace("...", "") + ) assert debug_info["components"]["function"] == "get_balance" - + # 5. Legacy session management assert manager.activate_session(call_id) is True assert manager.end_session(call_id) is True - + def test_multiple_function_tokens(self) -> None: """Test managing tokens for multiple functions""" manager = SessionManager() call_id = "multi_func_call" - + functions = ["get_balance", "transfer_funds", "get_history", "update_profile"] tokens = {} - + # Generate tokens for all functions for func in functions: tokens[func] = manager.generate_token(func, call_id) - + # Validate all tokens for func, token in tokens.items(): assert manager.validate_token(call_id, func, token) is True - + # Should be invalid for other functions for other_func in functions: if other_func != func: assert manager.validate_token(call_id, other_func, token) is False - + def test_concurrent_sessions(self) -> None: """Test managing multiple concurrent sessions""" manager = SessionManager() - + sessions = {} for i in range(5): call_id = f"call_{i}" sessions[call_id] = { "token": manager.generate_token("test_function", call_id) } - + # Validate all sessions for call_id, session_data in sessions.items(): - assert manager.validate_token(call_id, "test_function", session_data["token"]) is True - + assert ( + manager.validate_token(call_id, "test_function", session_data["token"]) + is True + ) + # Should be invalid for other call_ids - for other_call_id in sessions.keys(): + for other_call_id in sessions: if other_call_id != call_id: - assert manager.validate_token(other_call_id, "test_function", session_data["token"]) is False - + assert ( + manager.validate_token( + other_call_id, "test_function", session_data["token"] + ) + is False + ) + def test_token_expiry_workflow(self) -> None: """Test token expiry workflow""" manager = SessionManager(token_expiry_secs=2) # 2 second expiry - + call_id = "expiry_test" token = manager.generate_token("test_function", call_id) - + # Should be valid initially assert manager.validate_token(call_id, "test_function", token) is True - + # Wait for partial expiry time.sleep(1) assert manager.validate_token(call_id, "test_function", token) is True - + # Wait for full expiry time.sleep(2) assert manager.validate_token(call_id, "test_function", token) is False - + # Generate new token new_token = manager.generate_token("test_function", call_id) assert manager.validate_token(call_id, "test_function", new_token) is True - + def test_security_isolation(self) -> None: """Test security isolation between managers""" manager1 = SessionManager(secret_key="secret1" + "x" * 24) manager2 = SessionManager(secret_key="secret2" + "x" * 24) - + call_id = "security_test" function_name = "test_function" - + # Generate token with manager1 token1 = manager1.generate_token(function_name, call_id) - + # Should be valid with manager1 assert manager1.validate_token(call_id, function_name, token1) is True - + # Should be invalid with manager2 assert manager2.validate_token(call_id, function_name, token1) is False - + # Generate token with manager2 token2 = manager2.generate_token(function_name, call_id) - + # Should be valid with manager2 assert manager2.validate_token(call_id, function_name, token2) is True - + # Should be invalid with manager1 assert manager1.validate_token(call_id, function_name, token2) is False - + def test_performance_with_many_tokens(self) -> None: """Test performance with many token operations""" manager = SessionManager() - + # Generate many tokens tokens = [] for i in range(100): @@ -399,37 +405,43 @@ def test_performance_with_many_tokens(self) -> None: function_name = f"function_{i % 10}" # 10 different functions token = manager.generate_token(function_name, call_id) tokens.append((call_id, function_name, token)) - + # Validate all tokens for call_id, function_name, token in tokens: assert manager.validate_token(call_id, function_name, token) is True - + # Test cross-validation (should all fail) - for i, (call_id, function_name, token) in enumerate(tokens[:10]): + for i, (_call_id, _function_name, token) in enumerate(tokens[:10]): for j, (other_call_id, other_function_name, _) in enumerate(tokens[10:20]): if i != j: - assert manager.validate_token(other_call_id, other_function_name, token) is False - + assert ( + manager.validate_token( + other_call_id, other_function_name, token + ) + is False + ) + def test_token_structure_consistency(self) -> None: """Test token structure consistency""" manager = SessionManager() manager._debug_mode = True - + # Generate multiple tokens tokens = [] for i in range(10): token = manager.generate_token(f"func_{i}", f"call_{i}") tokens.append(token) - - # All tokens should be valid base64 + + # All tokens should be valid base64. Decoded directly (no try/except): a + # malformed token raises here and names itself, and the length assert now + # reports the ACTUAL part count instead of being swallowed by a broad + # `except Exception: pytest.fail(...)`. for token in tokens: - try: - decoded = base64.urlsafe_b64decode(token.encode()).decode() - parts = decoded.split('.') - assert len(parts) == 5 # call_id.function.expiry.nonce.signature - except Exception: - pytest.fail(f"Token {token} should have valid structure") - + decoded = base64.urlsafe_b64decode(token.encode()).decode() + parts = decoded.split(".") + # call_id.function.expiry.nonce.signature + assert len(parts) == 5, f"token {token} decoded to {parts!r}" + # Debug info should be consistent for i, token in enumerate(tokens): debug_info = manager.debug_token(token) @@ -438,4 +450,4 @@ def test_token_structure_consistency(self) -> None: assert debug_info["components"]["function"] == f"func_{i}" assert isinstance(debug_info["components"]["expiry"], str) assert isinstance(debug_info["components"]["nonce"], str) - assert isinstance(debug_info["components"]["signature"], str) \ No newline at end of file + assert isinstance(debug_info["components"]["signature"], str) diff --git a/tests/unit/core/test_skill_manager.py b/tests/unit/core/test_skill_manager.py index f80e22da..6c2d5207 100644 --- a/tests/unit/core/test_skill_manager.py +++ b/tests/unit/core/test_skill_manager.py @@ -11,11 +11,8 @@ Unit tests for SkillManager class """ -import pytest -import os from typing import Any, ClassVar -from unittest.mock import Mock, patch, MagicMock -from pathlib import Path +from unittest.mock import Mock, patch from signalwire.core.skill_manager import SkillManager from signalwire.core.skill_base import SkillBase @@ -24,6 +21,7 @@ class MockSkill(SkillBase): """Mock skill for testing""" + SKILL_NAME = "mock_skill" SKILL_DESCRIPTION = "A mock skill for testing" SKILL_VERSION = "1.0.0" @@ -38,7 +36,7 @@ def get_parameter_schema(cls) -> dict[str, Any]: "type": "string", "description": "A mock parameter", "default": "default_value", - "required": False + "required": False, } return schema @@ -58,12 +56,13 @@ def register_tools(self) -> None: name="mock_tool", description="A mock tool", parameters={"type": "object", "properties": {}}, - handler=lambda: {"result": "mock"} + handler=lambda: {"result": "mock"}, ) class FailingMockSkill(SkillBase): """Mock skill that fails setup""" + SKILL_NAME = "failing_skill" SKILL_DESCRIPTION = "A skill that fails setup" SKILL_VERSION = "1.0.0" @@ -75,7 +74,7 @@ def get_parameter_schema(cls) -> dict[str, Any]: schema["fail_param"] = { "type": "string", "description": "A fail parameter", - "required": False + "required": False, } return schema @@ -88,78 +87,82 @@ def register_tools(self) -> None: class TestSkillManagerBasic: """Test basic SkillManager functionality""" - + def test_initialization(self, mock_agent: AgentBase) -> None: """Test SkillManager initialization""" skill_manager = SkillManager(mock_agent) - + assert skill_manager.agent is mock_agent assert skill_manager.loaded_skills == {} - + def test_agent_reference(self, mock_agent: AgentBase) -> None: """Test that skill manager maintains agent reference""" skill_manager = SkillManager(mock_agent) - + assert skill_manager.agent is mock_agent class TestSkillManagerLoading: """Test skill loading functionality""" - + def test_load_skill_success(self, mock_agent: AgentBase) -> None: """Test successful skill loading""" skill_manager = SkillManager(mock_agent) - + success, error = skill_manager.load_skill("mock_skill", MockSkill) - + assert success is True assert error == "" assert len(skill_manager.loaded_skills) == 1 - + # Check that skill was properly initialized - skill_instance = list(skill_manager.loaded_skills.values())[0] + skill_instance = next(iter(skill_manager.loaded_skills.values())) assert isinstance(skill_instance, MockSkill) assert skill_instance.setup_called is True assert skill_instance.register_tools_called is True - + def test_load_skill_with_params(self, mock_agent: AgentBase) -> None: """Test loading skill with parameters""" skill_manager = SkillManager(mock_agent) - + params = {"param1": "value1", "param2": "value2"} - success, error = skill_manager.load_skill("mock_skill", MockSkill, params=params) - + success, error = skill_manager.load_skill( + "mock_skill", MockSkill, params=params + ) + assert success is True - skill_instance = list(skill_manager.loaded_skills.values())[0] + assert error == "" + skill_instance = next(iter(skill_manager.loaded_skills.values())) assert skill_instance.params == params - + def test_load_skill_setup_failure(self, mock_agent: AgentBase) -> None: """Test loading skill that fails setup""" skill_manager = SkillManager(mock_agent) - + success, error = skill_manager.load_skill("failing_skill", FailingMockSkill) - + assert success is False assert "Failed to setup skill" in error assert len(skill_manager.loaded_skills) == 0 - + def test_load_already_loaded_skill(self, mock_agent: AgentBase) -> None: """Test loading skill that's already loaded""" skill_manager = SkillManager(mock_agent) - + # Load first time success1, error1 = skill_manager.load_skill("mock_skill", MockSkill) assert success1 is True - + assert error1 == "" + # Load second time - should fail for single-instance skills success2, error2 = skill_manager.load_skill("mock_skill", MockSkill) assert success2 is False assert "already loaded" in error2 - + def test_load_skill_initialization_error(self, mock_agent: AgentBase) -> None: """Test loading skill that fails during initialization""" skill_manager = SkillManager(mock_agent) - + class BrokenSkill(SkillBase): SKILL_NAME = "broken_skill" SKILL_DESCRIPTION = "A broken skill" @@ -168,10 +171,16 @@ class BrokenSkill(SkillBase): @classmethod def get_parameter_schema(cls) -> dict[str, Any]: schema: dict[str, Any] = super().get_parameter_schema() - schema["broken_param"] = {"type": "string", "description": "A param", "required": False} + schema["broken_param"] = { + "type": "string", + "description": "A param", + "required": False, + } return schema - def __init__(self, agent: AgentBase, params: dict[str, Any] | None = None) -> None: + def __init__( + self, agent: AgentBase, params: dict[str, Any] | None = None + ) -> None: raise Exception("Initialization failed") def setup(self) -> bool: @@ -179,181 +188,189 @@ def setup(self) -> bool: def register_tools(self) -> None: pass - + success, error = skill_manager.load_skill("broken_skill", BrokenSkill) - + assert success is False assert "Error loading skill" in error - - def test_load_skill_without_class_registry_missing(self, mock_agent: AgentBase) -> None: + + def test_load_skill_without_class_registry_missing( + self, mock_agent: AgentBase + ) -> None: """Test loading skill without providing class when registry is missing""" skill_manager = SkillManager(mock_agent) - - with patch('signalwire.skills.registry.skill_registry') as mock_registry: + + with patch("signalwire.skills.registry.skill_registry") as mock_registry: mock_registry.get_skill_class.return_value = None - + success, error = skill_manager.load_skill("nonexistent_skill") - + assert success is False assert "not found in registry" in error class TestSkillManagerUnloading: """Test skill unloading functionality""" - + def test_unload_skill_success(self, mock_agent: AgentBase) -> None: """Test successful skill unloading""" skill_manager = SkillManager(mock_agent) - + # Load skill first skill_manager.load_skill("mock_skill", MockSkill) assert len(skill_manager.loaded_skills) == 1 - + # Get the instance key - instance_key = list(skill_manager.loaded_skills.keys())[0] - + instance_key = next(iter(skill_manager.loaded_skills.keys())) + # Unload skill success = skill_manager.unload_skill(instance_key) - + assert success is True assert len(skill_manager.loaded_skills) == 0 - + def test_unload_nonexistent_skill(self, mock_agent: AgentBase) -> None: """Test unloading non-existent skill""" skill_manager = SkillManager(mock_agent) - + success = skill_manager.unload_skill("nonexistent_skill") - + assert success is False - + def test_unload_skill_cleanup_called(self, mock_agent: AgentBase) -> None: """Test that cleanup is called during unloading""" skill_manager = SkillManager(mock_agent) - + class CleanupSkill(MockSkill): SKILL_NAME = "cleanup_skill" def cleanup(self) -> None: self.cleanup_called = True - + # Load and unload skill skill_manager.load_skill("cleanup_skill", CleanupSkill) - skill_instance = list(skill_manager.loaded_skills.values())[0] - instance_key = list(skill_manager.loaded_skills.keys())[0] - + skill_instance = next(iter(skill_manager.loaded_skills.values())) + instance_key = next(iter(skill_manager.loaded_skills.keys())) + skill_manager.unload_skill(instance_key) - + assert skill_instance.cleanup_called is True # type: ignore[attr-defined] # dynamic attr on CleanupSkill subclass class TestSkillManagerQueries: """Test skill query functionality""" - + def test_list_loaded_skills(self, mock_agent: AgentBase) -> None: """Test listing loaded skills""" skill_manager = SkillManager(mock_agent) - + # Initially empty loaded = skill_manager.list_loaded_skills() assert len(loaded) == 0 - + # Load skill - only one will load due to single instance restriction skill_manager.load_skill("skill1", MockSkill) # This will fail because MockSkill doesn't support multiple instances skill_manager.load_skill("skill2", MockSkill) - + loaded = skill_manager.list_loaded_skills() # Only one skill should be loaded due to single instance restriction assert len(loaded) == 1 - + def test_has_skill_loaded(self, mock_agent: AgentBase) -> None: """Test checking if skill is loaded""" skill_manager = SkillManager(mock_agent) - + # Not loaded initially assert skill_manager.has_skill("mock_skill") is False - + # Load skill skill_manager.load_skill("mock_skill", MockSkill) assert skill_manager.has_skill("mock_skill") is True - + # Unload skill - instance_key = list(skill_manager.loaded_skills.keys())[0] + instance_key = next(iter(skill_manager.loaded_skills.keys())) skill_manager.unload_skill(instance_key) assert skill_manager.has_skill("mock_skill") is False - + def test_has_skill_nonexistent(self, mock_agent: AgentBase) -> None: """Test checking for non-existent skill""" skill_manager = SkillManager(mock_agent) - + assert skill_manager.has_skill("nonexistent_skill") is False - + def test_get_skill_instance(self, mock_agent: AgentBase) -> None: """Test getting skill instance""" skill_manager = SkillManager(mock_agent) - + # Load skill skill_manager.load_skill("mock_skill", MockSkill) - + # Get instance by skill name instance = skill_manager.get_skill("mock_skill") assert isinstance(instance, MockSkill) assert instance.agent is mock_agent - + def test_get_skill_instance_not_loaded(self, mock_agent: AgentBase) -> None: """Test getting instance of non-loaded skill""" skill_manager = SkillManager(mock_agent) - + instance = skill_manager.get_skill("nonexistent_skill") assert instance is None class TestSkillManagerValidation: """Test skill validation functionality""" - + def test_validate_skill_requirements_success(self, mock_agent: AgentBase) -> None: """Test successful skill requirement validation""" skill_manager = SkillManager(mock_agent) - + class ValidSkill(MockSkill): SKILL_NAME = "valid_skill" REQUIRED_PACKAGES: ClassVar[list[str]] = [] # No requirements REQUIRED_ENV_VARS: ClassVar[list[str]] = [] - + success, error = skill_manager.load_skill("valid_skill", ValidSkill) assert success is True - + assert error == "" + def test_validate_skill_missing_env_vars(self, mock_agent: AgentBase) -> None: """Test skill with missing environment variables""" skill_manager = SkillManager(mock_agent) - + class EnvSkill(MockSkill): SKILL_NAME = "env_skill" - REQUIRED_ENV_VARS = ["MISSING_ENV_VAR"] - + REQUIRED_ENV_VARS: ClassVar[list[str]] = ["MISSING_ENV_VAR"] + success, error = skill_manager.load_skill("env_skill", EnvSkill) assert success is False assert "Missing required environment variables" in error - - def test_validate_skill_with_env_vars(self, mock_agent: AgentBase, mock_env_vars: dict[str, str]) -> None: + + def test_validate_skill_with_env_vars( + self, mock_agent: AgentBase, mock_env_vars: dict[str, str] + ) -> None: """Test skill with required environment variables present""" skill_manager = SkillManager(mock_agent) - + class EnvSkill(MockSkill): SKILL_NAME = "env_skill" - REQUIRED_ENV_VARS = ["SIGNALWIRE_PROJECT_ID"] # This is in mock_env_vars - + REQUIRED_ENV_VARS: ClassVar[list[str]] = [ + "SIGNALWIRE_PROJECT_ID" + ] # This is in mock_env_vars + success, error = skill_manager.load_skill("env_skill", EnvSkill) assert success is True - + assert error == "" + def test_validate_skill_missing_packages(self, mock_agent: AgentBase) -> None: """Test skill with missing packages""" skill_manager = SkillManager(mock_agent) - + class PackageSkill(MockSkill): SKILL_NAME = "package_skill" - REQUIRED_PACKAGES = ["nonexistent_package_xyz"] - + REQUIRED_PACKAGES: ClassVar[list[str]] = ["nonexistent_package_xyz"] + success, error = skill_manager.load_skill("package_skill", PackageSkill) assert success is False assert "Missing required packages" in error @@ -361,58 +378,61 @@ class PackageSkill(MockSkill): class TestSkillManagerErrorHandling: """Test error handling and edge cases""" - + def test_load_skill_exception_during_setup(self, mock_agent: AgentBase) -> None: """Test loading skill that raises exception during setup""" skill_manager = SkillManager(mock_agent) - + class ExceptionSkill(MockSkill): SKILL_NAME = "exception_skill" def setup(self) -> bool: raise Exception("Setup failed") - + success, error = skill_manager.load_skill("exception_skill", ExceptionSkill) - + assert success is False assert "Error loading skill" in error - - def test_load_skill_exception_during_register_tools(self, mock_agent: AgentBase) -> None: + + def test_load_skill_exception_during_register_tools( + self, mock_agent: AgentBase + ) -> None: """Test loading skill that raises exception during tool registration""" skill_manager = SkillManager(mock_agent) - + class ExceptionSkill(MockSkill): SKILL_NAME = "exception_skill" def register_tools(self) -> None: raise Exception("Tool registration failed") - + success, error = skill_manager.load_skill("exception_skill", ExceptionSkill) - + assert success is False assert "Error loading skill" in error class TestSkillManagerIntegration: """Test integration with other components""" - + def test_skill_tool_registration_with_agent(self, mock_agent: AgentBase) -> None: """Test that skill tools are properly registered with agent""" skill_manager = SkillManager(mock_agent) - + # Mock the agent's define_tool method mock_agent.define_tool = Mock() # type: ignore[method-assign] # mock - + success, error = skill_manager.load_skill("mock_skill", MockSkill) - + assert success is True + assert error == "" # Should have called agent.define_tool mock_agent.define_tool.assert_called_once() - + def test_multiple_skills_loaded(self, mock_agent: AgentBase) -> None: """Test loading multiple skills with different names""" skill_manager = SkillManager(mock_agent) - + class Skill1(MockSkill): SKILL_NAME = "skill1" @@ -422,7 +442,7 @@ def register_tools(self) -> None: name="skill1_tool", description="A skill1 tool", parameters={"type": "object", "properties": {}}, - handler=lambda: {"result": "skill1"} + handler=lambda: {"result": "skill1"}, ) class Skill2(MockSkill): @@ -434,25 +454,30 @@ def register_tools(self) -> None: name="skill2_tool", description="A skill2 tool", parameters={"type": "object", "properties": {}}, - handler=lambda: {"result": "skill2"} + handler=lambda: {"result": "skill2"}, ) # Load both skills - should work since they have different names and tools success1, _ = skill_manager.load_skill("skill1", Skill1) success2, _ = skill_manager.load_skill("skill2", Skill2) - + assert success1 is True assert success2 is True assert len(skill_manager.list_loaded_skills()) == 2 - + def test_skill_unload_cleanup_order(self, mock_agent: AgentBase) -> None: """Test that skills are cleaned up in proper order""" skill_manager = SkillManager(mock_agent) - + cleanup_order: list[str | None] = [] class OrderedSkill(MockSkill): - def __init__(self, agent: AgentBase, params: dict[str, Any] | None = None, skill_id: str | None = None) -> None: + def __init__( + self, + agent: AgentBase, + params: dict[str, Any] | None = None, + skill_id: str | None = None, + ) -> None: super().__init__(agent, params) self.skill_id = skill_id @@ -462,7 +487,10 @@ def cleanup(self) -> None: # Create skill classes with different IDs and names class Skill1(OrderedSkill): SKILL_NAME = "skill1" - def __init__(self, agent: AgentBase, params: dict[str, Any] | None = None) -> None: + + def __init__( + self, agent: AgentBase, params: dict[str, Any] | None = None + ) -> None: super().__init__(agent, params, "skill1") def register_tools(self) -> None: @@ -471,12 +499,15 @@ def register_tools(self) -> None: name="skill1_tool", description="A skill1 tool", parameters={"type": "object", "properties": {}}, - handler=lambda: {"result": "skill1"} + handler=lambda: {"result": "skill1"}, ) class Skill2(OrderedSkill): SKILL_NAME = "skill2" - def __init__(self, agent: AgentBase, params: dict[str, Any] | None = None) -> None: + + def __init__( + self, agent: AgentBase, params: dict[str, Any] | None = None + ) -> None: super().__init__(agent, params, "skill2") def register_tools(self) -> None: @@ -485,21 +516,21 @@ def register_tools(self) -> None: name="skill2_tool", description="A skill2 tool", parameters={"type": "object", "properties": {}}, - handler=lambda: {"result": "skill2"} + handler=lambda: {"result": "skill2"}, ) - + # Load skills skill_manager.load_skill("skill1", Skill1) skill_manager.load_skill("skill2", Skill2) - + # Get instance keys instance_keys = list(skill_manager.loaded_skills.keys()) - + # Should have 2 skills loaded assert len(instance_keys) == 2 - + # Unload in different order skill_manager.unload_skill(instance_keys[1]) skill_manager.unload_skill(instance_keys[0]) - - assert len(cleanup_order) == 2 \ No newline at end of file + + assert len(cleanup_order) == 2 diff --git a/tests/unit/core/test_swaig_function.py b/tests/unit/core/test_swaig_function.py index e1bcf4b6..21307ebf 100644 --- a/tests/unit/core/test_swaig_function.py +++ b/tests/unit/core/test_swaig_function.py @@ -11,69 +11,67 @@ Unit tests for SWAIGFunction class """ -import pytest import json from typing import Any -from unittest.mock import Mock, patch from signalwire.core.swaig_function import SWAIGFunction class TestSWAIGFunctionInitialization: """Test SWAIGFunction initialization""" - + def test_basic_initialization(self) -> None: """Test basic function initialization""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + func = SWAIGFunction( name="test_function", handler=test_handler, description="Test function", - parameters={"param1": {"type": "string"}} + parameters={"param1": {"type": "string"}}, ) - + assert func.name == "test_function" assert func.description == "Test function" assert func.parameters == {"param1": {"type": "string"}} assert func.handler == test_handler - + def test_initialization_with_all_parameters(self) -> None: """Test initialization with all parameters""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + func = SWAIGFunction( name="advanced_function", handler=test_handler, description="Advanced test function", - parameters={ - "param1": {"type": "string"}, - "param2": {"type": "integer"} - }, + parameters={"param1": {"type": "string"}, "param2": {"type": "integer"}}, secure=True, fillers={"thinking": ["Let me think...", "Processing..."]}, webhook_url="https://example.com/webhook", - custom_field="custom_value" + custom_field="custom_value", ) - + assert func.name == "advanced_function" assert func.description == "Advanced test function" assert func.secure is True assert func.fillers == {"thinking": ["Let me think...", "Processing..."]} assert func.webhook_url == "https://example.com/webhook" assert func.is_external is True - + def test_initialization_with_defaults(self) -> None: """Test initialization with default values""" + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( name="default_function", handler=test_handler, - description="Default test function" + description="Default test function", ) # Check default values @@ -84,18 +82,18 @@ def test_handler() -> dict[str, Any]: def test_is_typed_handler_defaults_false(self) -> None: """Test that is_typed_handler defaults to False""" + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( - name="test_function", - handler=test_handler, - description="Test function" + name="test_function", handler=test_handler, description="Test function" ) assert func.is_typed_handler is False def test_is_typed_handler_set_true(self) -> None: """Test that is_typed_handler can be set to True""" + def test_handler() -> dict[str, Any]: return {"result": "success"} @@ -103,161 +101,160 @@ def test_handler() -> dict[str, Any]: name="typed_function", handler=test_handler, description="Typed function", - is_typed_handler=True + is_typed_handler=True, ) assert func.is_typed_handler is True class TestSWAIGFunctionExecution: """Test function execution""" - + def test_execute_basic(self) -> None: """Test basic function execution""" + def test_handler(args: dict[str, Any], raw_data: Any) -> dict[str, Any]: return {"result": "success", "args": args} - + func = SWAIGFunction( - name="test_function", - handler=test_handler, - description="Test function" + name="test_function", handler=test_handler, description="Test function" ) - + result = func.execute({"param1": "value1"}, {"call_id": "123"}) - + assert isinstance(result, dict) assert "response" in result or "result" in result - + def test_execute_with_swaig_function_result(self) -> None: """Test execution returning FunctionResult""" from signalwire.core.function_result import FunctionResult - + def test_handler(args: dict[str, Any], raw_data: Any) -> FunctionResult: return FunctionResult("Function executed successfully") - + func = SWAIGFunction( - name="test_function", - handler=test_handler, - description="Test function" + name="test_function", handler=test_handler, description="Test function" ) - + result = func.execute({"param1": "value1"}, {"call_id": "123"}) - + assert isinstance(result, dict) assert "response" in result - + def test_execute_with_error_handling(self) -> None: """Test execution with error handling""" + def test_handler(args: dict[str, Any], raw_data: Any) -> None: raise ValueError("Test error") - + func = SWAIGFunction( - name="test_function", - handler=test_handler, - description="Test function" + name="test_function", handler=test_handler, description="Test function" ) - + result = func.execute({"param1": "value1"}, {"call_id": "123"}) - + # Should return error response, not raise exception assert isinstance(result, dict) assert "response" in result - + def test_call_method(self) -> None: """Test __call__ method""" + def test_handler(*args: Any, **kwargs: Any) -> dict[str, Any]: return {"args": args, "kwargs": kwargs} - + func = SWAIGFunction( - name="test_function", - handler=test_handler, - description="Test function" + name="test_function", handler=test_handler, description="Test function" ) - + result = func("arg1", param="value") assert result == {"args": ("arg1",), "kwargs": {"param": "value"}} class TestSWAIGFunctionSerialization: """Test function serialization""" - + def test_to_swaig_basic(self) -> None: """Test basic to_swaig conversion""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + func = SWAIGFunction( name="test_function", handler=test_handler, description="Test function", - parameters={"param1": {"type": "string"}} + parameters={"param1": {"type": "string"}}, ) - + swaig_dict = func.to_swaig("https://example.com") - + assert swaig_dict["function"] == "test_function" assert swaig_dict["description"] == "Test function" assert "parameters" in swaig_dict assert "web_hook_url" in swaig_dict - + def test_to_swaig_with_token(self) -> None: """Test to_swaig with token and call_id""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + func = SWAIGFunction( - name="test_function", - handler=test_handler, - description="Test function" + name="test_function", handler=test_handler, description="Test function" + ) + + swaig_dict = func.to_swaig( + "https://example.com", token="test-token", call_id="call-123" ) - - swaig_dict = func.to_swaig("https://example.com", token="test-token", call_id="call-123") - + assert "token=test-token" in swaig_dict["web_hook_url"] assert "call_id=call-123" in swaig_dict["web_hook_url"] - + def test_to_swaig_with_fillers(self) -> None: """Test to_swaig with fillers""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + fillers = {"thinking": ["Processing...", "Let me think..."]} - + func = SWAIGFunction( name="test_function", handler=test_handler, description="Test function", - fillers=fillers + fillers=fillers, ) - + swaig_dict = func.to_swaig("https://example.com") - + assert swaig_dict["fillers"] == fillers - + def test_ensure_parameter_structure(self) -> None: """Test parameter structure normalization""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + # Test with simple parameters func1 = SWAIGFunction( name="test_function", handler=test_handler, description="Test function", - parameters={"param1": {"type": "string"}} + parameters={"param1": {"type": "string"}}, ) - + structure = func1._ensure_parameter_structure() assert structure["type"] == "object" assert "properties" in structure - + # Test with already structured parameters func2 = SWAIGFunction( name="test_function", handler=test_handler, description="Test function", - parameters={"type": "object", "properties": {"param1": {"type": "string"}}} # type: ignore[dict-item] # valid mixed-value SWAIG parameter structure + parameters={"type": "object", "properties": {"param1": {"type": "string"}}}, # type: ignore[dict-item] # valid mixed-value SWAIG parameter structure ) - + structure = func2._ensure_parameter_structure() assert structure["type"] == "object" assert "properties" in structure @@ -265,92 +262,96 @@ def test_handler() -> dict[str, Any]: class TestSWAIGFunctionValidation: """Test function validation""" - + def test_validate_args(self) -> None: """Test argument validation""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + func = SWAIGFunction( name="test_function", handler=test_handler, description="Test function", - parameters={"param1": {"type": "string"}} + parameters={"param1": {"type": "string"}}, ) - + # validate_args returns (is_valid, errors) tuple is_valid, errors = func.validate_args({"param1": "value"}) assert is_valid is True + assert errors == [] is_valid, errors = func.validate_args({"invalid": "value"}) assert isinstance(is_valid, bool) - + assert isinstance(errors, list) + def test_function_name_validation(self) -> None: """Test function name validation""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + # Should accept valid function names - valid_names = ["test_function", "testFunction", "test123", "function_with_underscores"] - + valid_names = [ + "test_function", + "testFunction", + "test123", + "function_with_underscores", + ] + for name in valid_names: func = SWAIGFunction( - name=name, - handler=test_handler, - description="Test function" + name=name, handler=test_handler, description="Test function" ) assert func.name == name class TestSWAIGFunctionErrorHandling: """Test error handling and edge cases""" - + def test_none_handler(self) -> None: """Test handling of None handler""" func = SWAIGFunction( name="test_function", handler=None, # type: ignore[arg-type] # intentional invalid input for validation test - description="Test function" + description="Test function", ) - + assert func.handler is None - + def test_empty_function_name(self) -> None: """Test handling of empty function name""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - - func = SWAIGFunction( - name="", - handler=test_handler, - description="Test function" - ) - + + func = SWAIGFunction(name="", handler=test_handler, description="Test function") + assert func.name == "" - + def test_none_description(self) -> None: """Test handling of None description""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + func = SWAIGFunction( name="test_function", handler=test_handler, - description=None # type: ignore[arg-type] # intentional invalid input for validation test + description=None, # type: ignore[arg-type] # intentional invalid input for validation test ) assert func.description is None - + def test_execute_with_none_raw_data(self) -> None: """Test execution with None raw_data""" + def test_handler(args: dict[str, Any], raw_data: Any) -> dict[str, Any]: return {"args": args, "raw_data": raw_data} - + func = SWAIGFunction( - name="test_function", - handler=test_handler, - description="Test function" + name="test_function", handler=test_handler, description="Test function" ) - + # Should handle None raw_data gracefully result = func.execute({"param1": "value1"}, None) assert isinstance(result, dict) @@ -358,82 +359,86 @@ def test_handler(args: dict[str, Any], raw_data: Any) -> dict[str, Any]: class TestSWAIGFunctionIntegration: """Test integration functionality""" - + def test_external_webhook_configuration(self) -> None: """Test external webhook configuration""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + func = SWAIGFunction( name="webhook_function", handler=test_handler, description="Function with webhook", - webhook_url="https://example.com/webhook" + webhook_url="https://example.com/webhook", ) - + assert func.is_external is True assert func.webhook_url == "https://example.com/webhook" - + def test_security_configuration(self) -> None: """Test security configuration""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + # Secure function secure_func = SWAIGFunction( name="secure_function", handler=test_handler, description="Secure function", - secure=True + secure=True, ) - + # Non-secure function public_func = SWAIGFunction( name="public_function", handler=test_handler, description="Public function", - secure=False + secure=False, ) - + assert secure_func.secure is True assert public_func.secure is False - + def test_extra_swaig_fields(self) -> None: """Test extra SWAIG fields""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + func = SWAIGFunction( name="function_with_extras", handler=test_handler, description="Function with extra fields", custom_field="custom_value", - another_field={"nested": "data"} + another_field={"nested": "data"}, ) - + swaig_dict = func.to_swaig("https://example.com") - + assert swaig_dict["custom_field"] == "custom_value" assert swaig_dict["another_field"] == {"nested": "data"} - + def test_json_serialization(self) -> None: """Test JSON serialization of SWAIG output""" + def test_handler() -> dict[str, Any]: return {"result": "success"} - + func = SWAIGFunction( name="json_function", handler=test_handler, description="JSON test function", - parameters={"param1": {"type": "string"}} + parameters={"param1": {"type": "string"}}, ) - + swaig_dict = func.to_swaig("https://example.com") - + # Should be JSON serializable json_str = json.dumps(swaig_dict) assert isinstance(json_str, str) - + # Should be deserializable parsed = json.loads(json_str) - assert parsed["function"] == "json_function" \ No newline at end of file + assert parsed["function"] == "json_function" diff --git a/tests/unit/core/test_swml_builder.py b/tests/unit/core/test_swml_builder.py index 840f15de..33f132f2 100644 --- a/tests/unit/core/test_swml_builder.py +++ b/tests/unit/core/test_swml_builder.py @@ -12,8 +12,7 @@ """ import pytest -from unittest.mock import Mock, patch, MagicMock -from typing import Dict, List, Any, Optional +from unittest.mock import Mock from signalwire.core.swml_builder import SWMLBuilder from signalwire.core.swml_service import SWMLService @@ -21,14 +20,14 @@ class TestSWMLBuilder: """Test SWMLBuilder functionality""" - + def test_basic_initialization(self) -> None: """Test basic SWMLBuilder initialization""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) - + assert builder.service is mock_service - + def test_answer_verb(self) -> None: """Test adding answer verb""" mock_service = Mock(spec=SWMLService) @@ -47,7 +46,9 @@ def test_answer_verb_with_params(self) -> None: result = builder.answer(max_duration=30, codecs="PCMU,PCMA") assert result is builder - mock_service.add_verb.assert_called_once_with("answer", {"max_duration": 30, "codecs": "PCMU,PCMA"}) + mock_service.add_verb.assert_called_once_with( + "answer", {"max_duration": 30, "codecs": "PCMU,PCMA"} + ) def test_hangup_verb(self) -> None: """Test adding hangup verb""" @@ -77,7 +78,9 @@ def test_ai_verb_basic(self) -> None: result = builder.ai(prompt_text="You are helpful") assert result is builder - mock_service.add_verb.assert_called_once_with("ai", {"prompt": {"text": "You are helpful"}}) + mock_service.add_verb.assert_called_once_with( + "ai", {"prompt": {"text": "You are helpful"}} + ) def test_ai_verb_with_pom(self) -> None: """Test adding AI verb with POM""" @@ -88,7 +91,9 @@ def test_ai_verb_with_pom(self) -> None: result = builder.ai(prompt_pom=pom_data) assert result is builder - mock_service.add_verb.assert_called_once_with("ai", {"prompt": {"pom": pom_data}}) + mock_service.add_verb.assert_called_once_with( + "ai", {"prompt": {"pom": pom_data}} + ) def test_ai_verb_with_swaig(self) -> None: """Test adding AI verb with SWAIG configuration""" @@ -101,10 +106,9 @@ def test_ai_verb_with_swaig(self) -> None: result = builder.ai(prompt_text="You are helpful", swaig=swaig_config) assert result is builder - mock_service.add_verb.assert_called_once_with("ai", { - "prompt": {"text": "You are helpful"}, - "SWAIG": swaig_config - }) + mock_service.add_verb.assert_called_once_with( + "ai", {"prompt": {"text": "You are helpful"}, "SWAIG": swaig_config} + ) def test_ai_verb_with_kwargs(self) -> None: """Test adding AI verb with additional parameters""" @@ -112,151 +116,147 @@ def test_ai_verb_with_kwargs(self) -> None: builder = SWMLBuilder(mock_service) result = builder.ai( - prompt_text="You are helpful", - temperature=0.7, - max_tokens=150 + prompt_text="You are helpful", temperature=0.7, max_tokens=150 ) assert result is builder - mock_service.add_verb.assert_called_once_with("ai", { - "prompt": {"text": "You are helpful"}, - "temperature": 0.7, - "max_tokens": 150 - }) - + mock_service.add_verb.assert_called_once_with( + "ai", + { + "prompt": {"text": "You are helpful"}, + "temperature": 0.7, + "max_tokens": 150, + }, + ) + def test_play_verb_with_url(self) -> None: """Test adding play verb with single URL""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) - + result = builder.play(url="test.mp3") - + assert result is builder mock_service.add_verb.assert_called_once_with("play", {"url": "test.mp3"}) - + def test_play_verb_with_urls(self) -> None: """Test adding play verb with multiple URLs""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) - + urls = ["test1.mp3", "test2.mp3"] result = builder.play(urls=urls) - + assert result is builder mock_service.add_verb.assert_called_once_with("play", {"urls": urls}) - + def test_play_verb_with_options(self) -> None: """Test adding play verb with options""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) - + result = builder.play( - url="test.mp3", - volume=0.8, - say_voice="alice", - say_language="en-US" + url="test.mp3", volume=0.8, say_voice="alice", say_language="en-US" ) - + assert result is builder expected_config = { "url": "test.mp3", "volume": 0.8, "say_voice": "alice", - "say_language": "en-US" + "say_language": "en-US", } mock_service.add_verb.assert_called_once_with("play", expected_config) - + def test_play_verb_no_url_error(self) -> None: """Test play verb raises error when no URL provided""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) - + with pytest.raises(ValueError, match="Either url or urls must be provided"): builder.play() - + def test_say_verb(self) -> None: """Test adding say verb""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) - + result = builder.say("Hello world") - + assert result is builder - mock_service.add_verb.assert_called_once_with("play", {"url": "say:Hello world"}) - + mock_service.add_verb.assert_called_once_with( + "play", {"url": "say:Hello world"} + ) + def test_say_verb_with_options(self) -> None: """Test adding say verb with options""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) - - result = builder.say( - "Hello world", - voice="alice", - language="en-US", - volume=0.7 - ) - + + result = builder.say("Hello world", voice="alice", language="en-US", volume=0.7) + assert result is builder expected_config = { "url": "say:Hello world", "say_voice": "alice", "say_language": "en-US", - "volume": 0.7 + "volume": 0.7, } mock_service.add_verb.assert_called_once_with("play", expected_config) - + def test_add_section(self) -> None: """Test adding section""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) - + result = builder.add_section("greeting") - + assert result is builder mock_service.add_section.assert_called_once_with("greeting") - + def test_build(self) -> None: """Test building document""" mock_service = Mock(spec=SWMLService) - mock_service.get_document.return_value = {"version": "1.0.0", "sections": {"main": []}} + mock_service.get_document.return_value = { + "version": "1.0.0", + "sections": {"main": []}, + } builder = SWMLBuilder(mock_service) - + result = builder.build() - + assert result == {"version": "1.0.0", "sections": {"main": []}} mock_service.get_document.assert_called_once() - + def test_render(self) -> None: """Test rendering document""" mock_service = Mock(spec=SWMLService) mock_service.render_document.return_value = '{"version": "1.0.0"}' builder = SWMLBuilder(mock_service) - + result = builder.render() - + assert result == '{"version": "1.0.0"}' mock_service.render_document.assert_called_once() - + def test_reset(self) -> None: """Test resetting document""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) - + result = builder.reset() - + assert result is builder mock_service.reset_document.assert_called_once() - + def test_method_chaining(self) -> None: """Test method chaining functionality""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) - result = (builder - .answer() - .say("Hello") - .ai(prompt_text="You are helpful") - .hangup()) + result = ( + builder.answer().say("Hello").ai(prompt_text="You are helpful").hangup() + ) assert result is builder @@ -271,18 +271,18 @@ def test_method_chaining(self) -> None: class TestSWMLBuilderErrorHandling: """Test error handling in SWMLBuilder""" - + def test_initialization_without_service(self) -> None: """Test initialization without service raises error""" with pytest.raises(TypeError): SWMLBuilder() # type: ignore[call-arg] # intentional invalid input for validation test - + def test_initialization_with_none_service(self) -> None: """Test initialization with None service""" # SWMLBuilder should accept None but it will fail when methods are called builder = SWMLBuilder(None) # type: ignore[arg-type] # intentional invalid input for validation test assert builder.service is None - + def test_service_method_errors_propagate(self) -> None: """Test that service method errors propagate""" mock_service = Mock(spec=SWMLService) @@ -295,7 +295,7 @@ def test_service_method_errors_propagate(self) -> None: class TestSWMLBuilderIntegration: """Test integration scenarios""" - + def test_complete_agent_workflow(self) -> None: """Test complete agent building workflow""" mock_service = Mock(spec=SWMLService) @@ -306,50 +306,52 @@ def test_complete_agent_workflow(self) -> None: {"answer": {}}, {"play": {"url": "say:Welcome!"}}, {"ai": {"prompt": {"text": "You are helpful"}}}, - {"hangup": {"reason": "completed"}} + {"hangup": {"reason": "completed"}}, ] - } + }, } builder = SWMLBuilder(mock_service) # Build a complete workflow - result = (builder - .answer() - .say("Welcome!") - .ai(prompt_text="You are helpful") - .hangup(reason="completed") - .build()) + result = ( + builder.answer() + .say("Welcome!") + .ai(prompt_text="You are helpful") + .hangup(reason="completed") + .build() + ) # Verify the document structure assert result["version"] == "1.0.0" assert "sections" in result assert "main" in result["sections"] assert len(result["sections"]["main"]) == 4 - + def test_multi_section_workflow(self) -> None: """Test multi-section workflow""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) - + # Build workflow with multiple sections - result = (builder - .add_section("greeting") - .say("Hello!") - .add_section("main") - .ai(prompt_text="You are helpful") - .add_section("goodbye") - .say("Goodbye!") - .hangup()) - + result = ( + builder.add_section("greeting") + .say("Hello!") + .add_section("main") + .ai(prompt_text="You are helpful") + .add_section("goodbye") + .say("Goodbye!") + .hangup() + ) + assert result is builder - + # Verify sections were added assert mock_service.add_section.call_count == 3 mock_service.add_section.assert_any_call("greeting") mock_service.add_section.assert_any_call("main") mock_service.add_section.assert_any_call("goodbye") - + def test_complex_ai_configuration(self) -> None: """Test complex AI configuration""" mock_service = Mock(spec=SWMLService) @@ -363,12 +365,10 @@ def test_complex_ai_configuration(self) -> None: "description": "Get weather information", "parameters": { "type": "object", - "properties": { - "location": {"type": "string"} - } - } + "properties": {"location": {"type": "string"}}, + }, } - ] + ], } result = builder.ai( @@ -377,19 +377,22 @@ def test_complex_ai_configuration(self) -> None: post_prompt_url="https://example.com/summary", swaig=swaig_config, temperature=0.7, - max_tokens=150 + max_tokens=150, ) assert result is builder - mock_service.add_verb.assert_called_once_with("ai", { - "prompt": {"text": "You are a weather assistant"}, - "post_prompt": {"text": "Summarize the weather information provided"}, - "post_prompt_url": "https://example.com/summary", - "SWAIG": swaig_config, - "temperature": 0.7, - "max_tokens": 150 - }) - + mock_service.add_verb.assert_called_once_with( + "ai", + { + "prompt": {"text": "You are a weather assistant"}, + "post_prompt": {"text": "Summarize the weather information provided"}, + "post_prompt_url": "https://example.com/summary", + "SWAIG": swaig_config, + "temperature": 0.7, + "max_tokens": 150, + }, + ) + def test_service_delegation(self) -> None: """Test that builder properly delegates to service""" real_service = SWMLService(name="test_service", schema_validation=False) @@ -408,4 +411,4 @@ def test_service_delegation(self) -> None: # Verify verbs were added main_section = document["sections"]["main"] - assert len(main_section) > 0 \ No newline at end of file + assert len(main_section) > 0 diff --git a/tests/unit/core/test_swml_handler.py b/tests/unit/core/test_swml_handler.py index e3a2a095..92055bff 100644 --- a/tests/unit/core/test_swml_handler.py +++ b/tests/unit/core/test_swml_handler.py @@ -12,62 +12,61 @@ """ import pytest -from unittest.mock import Mock, patch, MagicMock -from typing import Dict, List, Any, Optional, Tuple +from typing import Any from signalwire.core.swml_handler import ( SWMLVerbHandler, AIVerbHandler, - VerbHandlerRegistry + VerbHandlerRegistry, ) class MockVerbHandler(SWMLVerbHandler): """Mock implementation of SWMLVerbHandler for testing""" - + def __init__(self, verb_name: str = "mock_verb"): self.verb_name = verb_name - + def get_verb_name(self) -> str: return self.verb_name - - def validate_config(self, config: Dict[str, Any]) -> Tuple[bool, List[str]]: + + def validate_config(self, config: dict[str, Any]) -> tuple[bool, list[str]]: # Simple validation - require 'required_field' - if 'required_field' not in config: + if "required_field" not in config: return False, ["Missing required_field"] return True, [] - - def build_config(self, **kwargs: Any) -> Dict[str, Any]: + + def build_config(self, **kwargs: Any) -> dict[str, Any]: return {"mock_config": True, **kwargs} class TestSWMLVerbHandlerInterface: """Test SWMLVerbHandler abstract interface""" - + def test_abstract_methods_exist(self) -> None: """Test that SWMLVerbHandler defines required abstract methods""" # Should not be able to instantiate abstract class directly with pytest.raises(TypeError): SWMLVerbHandler() # type: ignore[abstract] # intentional: testing abstract base raises - + def test_mock_implementation(self) -> None: """Test mock implementation of SWMLVerbHandler""" handler = MockVerbHandler("test_verb") - + # Test get_verb_name assert handler.get_verb_name() == "test_verb" - + # Test validate_config valid_config = {"required_field": "value"} is_valid, errors = handler.validate_config(valid_config) assert is_valid is True assert errors == [] - + invalid_config = {"other_field": "value"} is_valid, errors = handler.validate_config(invalid_config) assert is_valid is False assert "Missing required_field" in errors - + # Test build_config config = handler.build_config(param1="value1", param2="value2") assert config["mock_config"] is True @@ -77,43 +76,39 @@ def test_mock_implementation(self) -> None: class TestAIVerbHandler: """Test AIVerbHandler implementation""" - + def test_initialization(self) -> None: """Test AIVerbHandler initialization""" handler = AIVerbHandler() assert handler.get_verb_name() == "ai" - + def test_validate_config_valid_prompt_text(self) -> None: """Test validation with valid prompt text configuration""" handler = AIVerbHandler() - - config = { - "prompt": { - "text": "You are a helpful assistant" - } - } - + + config = {"prompt": {"text": "You are a helpful assistant"}} + is_valid, errors = handler.validate_config(config) assert is_valid is True assert errors == [] - + def test_validate_config_valid_prompt_pom(self) -> None: """Test validation with valid prompt POM configuration""" handler = AIVerbHandler() - + config = { "prompt": { "pom": [ {"title": "Section 1", "body": "Content 1"}, - {"title": "Section 2", "body": "Content 2"} + {"title": "Section 2", "body": "Content 2"}, ] } } - + is_valid, errors = handler.validate_config(config) assert is_valid is True assert errors == [] - + def test_validate_config_valid_contexts(self) -> None: """Test validation with contexts requires base prompt (text or pom)""" handler = AIVerbHandler() @@ -122,11 +117,7 @@ def test_validate_config_valid_contexts(self) -> None: config = { "prompt": { "contexts": { - "context1": { - "steps": [ - {"step": "greeting", "content": "Hello"} - ] - } + "context1": {"steps": [{"step": "greeting", "content": "Hello"}]} } } } @@ -143,211 +134,194 @@ def test_validate_config_text_with_contexts(self) -> None: "prompt": { "text": "You are a helpful assistant", "contexts": { - "context1": { - "steps": [ - {"step": "greeting", "content": "Hello"} - ] - } - } + "context1": {"steps": [{"step": "greeting", "content": "Hello"}]} + }, } } is_valid, errors = handler.validate_config(config) assert is_valid is True assert errors == [] - + def test_validate_config_missing_prompt(self) -> None: """Test validation fails when prompt is missing""" handler = AIVerbHandler() - - config = { - "post_prompt": {"text": "Summary"} - } - + + config = {"post_prompt": {"text": "Summary"}} + is_valid, errors = handler.validate_config(config) assert is_valid is False assert "Missing required field 'prompt'" in errors - + def test_validate_config_both_prompt_options(self) -> None: """Test validation fails when multiple prompt options are specified""" handler = AIVerbHandler() - - config = { - "prompt": { - "text": "You are helpful", - "pom": [{"title": "Section"}] - } - } - + + config = {"prompt": {"text": "You are helpful", "pom": [{"title": "Section"}]}} + is_valid, errors = handler.validate_config(config) assert is_valid is False - assert "'prompt' can only contain one of: 'text' or 'pom' (mutually exclusive)" in errors - + assert ( + "'prompt' can only contain one of: 'text' or 'pom' (mutually exclusive)" + in errors + ) + def test_validate_config_invalid_prompt_structure(self) -> None: """Test validation fails with invalid prompt structure""" handler = AIVerbHandler() - + config = { "prompt": "invalid_string_prompt" # Should be dict } - + is_valid, errors = handler.validate_config(config) assert is_valid is False assert "'prompt' must be an object" in errors - + def test_validate_config_prompt_missing_content(self) -> None: """Test validation fails when prompt dict has no valid content""" handler = AIVerbHandler() - + config = { "prompt": {"other_field": "value"} # Missing text, pom, or contexts } - + is_valid, errors = handler.validate_config(config) assert is_valid is False assert "'prompt' must contain either 'text' or 'pom' as base prompt" in errors - + def test_validate_config_invalid_contexts_structure(self) -> None: """Test validation fails with invalid contexts structure""" handler = AIVerbHandler() - + config = { "prompt": { "contexts": "invalid_string_contexts" # Should be dict } } - + is_valid, errors = handler.validate_config(config) assert is_valid is False assert "'prompt.contexts' must be an object" in errors - + def test_build_config_with_prompt_text(self) -> None: """Test building config with prompt text""" handler = AIVerbHandler() - + config = handler.build_config( prompt_text="You are a helpful assistant", post_prompt="Provide a summary", - post_prompt_url="https://example.com/summary" + post_prompt_url="https://example.com/summary", ) - + assert config["prompt"]["text"] == "You are a helpful assistant" assert config["post_prompt"]["text"] == "Provide a summary" assert config["post_prompt_url"] == "https://example.com/summary" - + def test_build_config_with_prompt_pom(self) -> None: """Test building config with prompt POM""" handler = AIVerbHandler() - + pom_data = [ {"title": "Section 1", "body": "Content 1"}, - {"title": "Section 2", "body": "Content 2"} + {"title": "Section 2", "body": "Content 2"}, ] - - config = handler.build_config( - prompt_pom=pom_data, - post_prompt="Summary" - ) - + + config = handler.build_config(prompt_pom=pom_data, post_prompt="Summary") + assert config["prompt"]["pom"] == pom_data assert config["post_prompt"]["text"] == "Summary" - + def test_build_config_with_contexts(self) -> None: """Test building config with contexts combined with text""" handler = AIVerbHandler() contexts_data = { - "context1": { - "steps": [ - {"step": "greeting", "content": "Hello"} - ] - } + "context1": {"steps": [{"step": "greeting", "content": "Hello"}]} } config = handler.build_config( prompt_text="You are a helpful assistant", contexts=contexts_data, - post_prompt="Summary" + post_prompt="Summary", ) assert config["prompt"]["text"] == "You are a helpful assistant" assert config["prompt"]["contexts"] == contexts_data assert config["post_prompt"]["text"] == "Summary" - + def test_build_config_with_swaig(self) -> None: """Test building config with SWAIG object""" handler = AIVerbHandler() - + swaig_data = { - "functions": [ - {"function": "test_func", "description": "Test function"} - ] + "functions": [{"function": "test_func", "description": "Test function"}] } - - config = handler.build_config( - prompt_text="You are helpful", - swaig=swaig_data - ) - + + config = handler.build_config(prompt_text="You are helpful", swaig=swaig_data) + assert config["prompt"]["text"] == "You are helpful" assert config["SWAIG"] == swaig_data - + def test_build_config_minimal(self) -> None: """Test building minimal config""" handler = AIVerbHandler() - + config = handler.build_config(prompt_text="Hello") - + assert config["prompt"]["text"] == "Hello" assert "post_prompt" not in config assert "post_prompt_url" not in config assert "SWAIG" not in config - + def test_build_config_validation_error(self) -> None: """Test that build_config raises error for invalid configuration""" handler = AIVerbHandler() - + # Should raise ValueError when no prompt options provided - with pytest.raises(ValueError, match="Either prompt_text or prompt_pom must be provided as base prompt"): + with pytest.raises( + ValueError, + match="Either prompt_text or prompt_pom must be provided as base prompt", + ): handler.build_config() - + def test_build_config_conflicting_prompts(self) -> None: """Test that build_config raises error for conflicting prompt types""" handler = AIVerbHandler() # Should raise ValueError when both prompt_text and prompt_pom provided - with pytest.raises(ValueError, match="prompt_text and prompt_pom are mutually exclusive"): + with pytest.raises( + ValueError, match="prompt_text and prompt_pom are mutually exclusive" + ): handler.build_config( - prompt_text="Text prompt", - prompt_pom=[{"title": "POM section"}] + prompt_text="Text prompt", prompt_pom=[{"title": "POM section"}] ) - + def test_build_config_prompt_and_contexts_combined(self) -> None: """Test that build_config allows combining text with contexts""" handler = AIVerbHandler() # Contexts can be combined with text or pom (they are optional) config = handler.build_config( - prompt_text="Text prompt", - contexts={"context1": {"steps": []}} + prompt_text="Text prompt", contexts={"context1": {"steps": []}} ) assert config["prompt"]["text"] == "Text prompt" assert config["prompt"]["contexts"] == {"context1": {"steps": []}} - + def test_build_config_with_additional_params(self) -> None: """Test building config with additional parameters""" handler = AIVerbHandler() - + config = handler.build_config( prompt_text="Hello", languages=["en", "es"], hints=["hint1", "hint2"], pronounce={"word": "pronunciation"}, global_data={"key": "value"}, - custom_param="custom_value" + custom_param="custom_value", ) - + assert config["prompt"]["text"] == "Hello" assert config["languages"] == ["en", "es"] assert config["hints"] == ["hint1", "hint2"] @@ -358,86 +332,86 @@ def test_build_config_with_additional_params(self) -> None: class TestVerbHandlerRegistry: """Test VerbHandlerRegistry functionality""" - + def test_initialization(self) -> None: """Test registry initialization""" registry = VerbHandlerRegistry() - + # Should have AI handler registered by default assert registry.has_handler("ai") ai_handler = registry.get_handler("ai") assert isinstance(ai_handler, AIVerbHandler) - + def test_register_handler(self) -> None: """Test registering a new handler""" registry = VerbHandlerRegistry() mock_handler = MockVerbHandler("custom_verb") - + registry.register_handler(mock_handler) - + assert registry.has_handler("custom_verb") retrieved_handler = registry.get_handler("custom_verb") assert retrieved_handler is mock_handler - + def test_get_handler_existing(self) -> None: """Test getting an existing handler""" registry = VerbHandlerRegistry() - + handler = registry.get_handler("ai") assert handler is not None assert isinstance(handler, AIVerbHandler) - + def test_get_handler_nonexistent(self) -> None: """Test getting a non-existent handler""" registry = VerbHandlerRegistry() - + handler = registry.get_handler("nonexistent_verb") assert handler is None - + def test_has_handler_existing(self) -> None: """Test checking for existing handler""" registry = VerbHandlerRegistry() - + assert registry.has_handler("ai") is True - + def test_has_handler_nonexistent(self) -> None: """Test checking for non-existent handler""" registry = VerbHandlerRegistry() - + assert registry.has_handler("nonexistent_verb") is False - + def test_override_existing_handler(self) -> None: """Test overriding an existing handler""" registry = VerbHandlerRegistry() - + # Register a custom AI handler custom_ai_handler = MockVerbHandler("ai") registry.register_handler(custom_ai_handler) - + # Should replace the default AI handler retrieved_handler = registry.get_handler("ai") assert retrieved_handler is custom_ai_handler assert isinstance(retrieved_handler, MockVerbHandler) - + def test_multiple_handlers(self) -> None: """Test registering multiple handlers""" registry = VerbHandlerRegistry() - + # Register multiple custom handlers handler1 = MockVerbHandler("verb1") handler2 = MockVerbHandler("verb2") handler3 = MockVerbHandler("verb3") - + registry.register_handler(handler1) registry.register_handler(handler2) registry.register_handler(handler3) - + # All should be accessible assert registry.has_handler("verb1") assert registry.has_handler("verb2") assert registry.has_handler("verb3") assert registry.has_handler("ai") # Default should still exist - + assert registry.get_handler("verb1") is handler1 assert registry.get_handler("verb2") is handler2 assert registry.get_handler("verb3") is handler3 @@ -445,11 +419,11 @@ def test_multiple_handlers(self) -> None: class TestSWMLHandlerIntegration: """Test integration scenarios for SWML handlers""" - + def test_ai_handler_complete_workflow(self) -> None: """Test complete workflow with AI handler""" handler = AIVerbHandler() - + # Build a complete configuration config = handler.build_config( prompt_text="You are a helpful assistant", @@ -460,78 +434,76 @@ def test_ai_handler_complete_workflow(self) -> None: { "function": "get_weather", "description": "Get weather information", - "parameters": {"type": "object", "properties": {}} + "parameters": {"type": "object", "properties": {}}, } ] - } + }, ) - + # Validate the configuration is_valid, errors = handler.validate_config(config) assert is_valid is True assert errors == [] - + # Verify structure assert config["prompt"]["text"] == "You are a helpful assistant" assert config["post_prompt"]["text"] == "Provide a brief summary" assert config["post_prompt_url"] == "https://example.com/summary" assert "SWAIG" in config assert len(config["SWAIG"]["functions"]) == 1 - + def test_registry_with_custom_handlers(self) -> None: """Test registry with custom handlers""" registry = VerbHandlerRegistry() - + # Create custom handlers play_handler = MockVerbHandler("play") say_handler = MockVerbHandler("say") - + # Register them registry.register_handler(play_handler) registry.register_handler(say_handler) - + # Test that all handlers work handlers = ["ai", "play", "say"] for verb_name in handlers: assert registry.has_handler(verb_name) handler = registry.get_handler(verb_name) assert handler is not None - + # Test basic functionality if verb_name == "ai": config = handler.build_config(prompt_text="Test") is_valid, errors = handler.validate_config(config) - assert is_valid is True else: config = handler.build_config(required_field="test") is_valid, errors = handler.validate_config(config) - assert is_valid is True - + assert is_valid is True + assert errors == [] + def test_handler_error_scenarios(self) -> None: """Test error handling scenarios""" handler = AIVerbHandler() - + # Test various invalid configurations - invalid_configs: List[Dict[str, Any]] = [ + invalid_configs: list[dict[str, Any]] = [ {}, # Empty config - missing prompt {"prompt": {}}, # Empty prompt - no content {"prompt": {"invalid": "field"}}, # Invalid prompt field {"prompt": {"text": "test", "pom": []}}, # Both text and pom ] - + for config in invalid_configs: is_valid, errors = handler.validate_config(config) assert is_valid is False assert len(errors) > 0 - + def test_handler_with_complex_swaig(self) -> None: """Test handler with complex SWAIG configuration""" handler = AIVerbHandler() - + complex_swaig = { - "defaults": { - "web_hook_url": "https://example.com/webhook" - }, + "defaults": {"web_hook_url": "https://example.com/webhook"}, "functions": [ { "function": "search", @@ -541,11 +513,9 @@ def test_handler_with_complex_swaig(self) -> None: "properties": { "query": {"type": "string", "description": "Search query"} }, - "required": ["query"] + "required": ["query"], }, - "fillers": { - "en": ["Searching...", "Looking that up..."] - } + "fillers": {"en": ["Searching...", "Looking that up..."]}, }, { "function": "calculate", @@ -553,29 +523,31 @@ def test_handler_with_complex_swaig(self) -> None: "parameters": { "type": "object", "properties": { - "expression": {"type": "string", "description": "Math expression"} - } - } - } + "expression": { + "type": "string", + "description": "Math expression", + } + }, + }, + }, ], "includes": [ { "url": "https://api.example.com/functions", - "functions": ["external_func1", "external_func2"] + "functions": ["external_func1", "external_func2"], } - ] + ], } - + config = handler.build_config( - prompt_text="You are a calculator and search assistant", - swaig=complex_swaig + prompt_text="You are a calculator and search assistant", swaig=complex_swaig ) - + # Validate the complex configuration is_valid, errors = handler.validate_config(config) assert is_valid is True assert errors == [] - + # Verify SWAIG structure is preserved assert config["SWAIG"] == complex_swaig assert len(config["SWAIG"]["functions"]) == 2 @@ -585,64 +557,124 @@ def test_handler_with_complex_swaig(self) -> None: class TestSWMLHandlerEdgeCases: """Test edge cases and error conditions""" - + def test_handler_with_none_values(self) -> None: """Test handler behavior with None values""" handler = AIVerbHandler() - + # Should handle None values gracefully config = handler.build_config( - prompt_text="Test", - post_prompt=None, - post_prompt_url=None, - swaig=None + prompt_text="Test", post_prompt=None, post_prompt_url=None, swaig=None ) - + assert config["prompt"]["text"] == "Test" assert "post_prompt" not in config assert "post_prompt_url" not in config assert "SWAIG" not in config - + def test_handler_with_empty_strings(self) -> None: """Test handler behavior with empty strings""" handler = AIVerbHandler() - + # Should handle empty strings appropriately config = handler.build_config( prompt_text="", # Empty but valid post_prompt="", - post_prompt_url="" + post_prompt_url="", ) - + assert config["prompt"]["text"] == "" assert config["post_prompt"]["text"] == "" assert config["post_prompt_url"] == "" # Empty URL is included if provided - + def test_registry_with_invalid_handler(self) -> None: """Test registry behavior with invalid handler""" registry = VerbHandlerRegistry() - + # Try to register an invalid handler (not implementing interface) invalid_handler = "not_a_handler" - + # Should raise AttributeError when trying to get verb name with pytest.raises(AttributeError): registry.register_handler(invalid_handler) # type: ignore[arg-type] # intentional invalid input for validation test - + def test_mock_handler_edge_cases(self) -> None: """Test mock handler edge cases""" handler = MockVerbHandler() - + # Test with empty config is_valid, errors = handler.validate_config({}) assert is_valid is False assert len(errors) == 1 - + # Test with None config with pytest.raises(TypeError): handler.validate_config(None) # type: ignore[arg-type] # intentional: tests None input - + # Test build_config with no arguments config = handler.build_config() assert config["mock_config"] is True - assert len(config) == 1 \ No newline at end of file + assert len(config) == 1 + + +class TestPostPromptShapeValidated: + """``post_prompt`` must be validated the same way ``prompt`` is. + + THE ENGINE TREATS THEM IDENTICALLY. ``mod_openai/app_config.c`` checks + ``!cJSON_IsObject(assistant_prompt)`` at :3193 and + ``!cJSON_IsObject(post_prompt)`` at :3219 — same structure, same + ``fatal: true`` ``calling.error``, and both error payloads read "must be an + object with 'text' or 'pom' field". ``post_prompt``'s even names the array + case explicitly ("not an array"). + + ``build_config`` has always emitted the right shape (``{"text": ...}``), so + no reference code path produced bad wire. The hole was in ``validate_config``, + which is PUBLIC surface taking a caller-supplied dict: it checked ``prompt`` + four ways and ``post_prompt`` zero times, so a hand-assembled config that + ABORTS THE CALL was reported valid. + + That blind spot was not theoretical — it is exactly how signalwire-go + shipped a bare-string ``post_prompt`` (fixed in go 51934ec): go's validator + faithfully mirrored this one, so nothing flagged it. + """ + + def test_bare_string_post_prompt_is_rejected(self) -> None: + handler = AIVerbHandler() + is_valid, errors = handler.validate_config( + {"prompt": {"text": "You are helpful."}, "post_prompt": "Summarize."} + ) + assert is_valid is False + assert "'post_prompt' must be an object" in errors + + def test_array_post_prompt_is_rejected(self) -> None: + """The array case the engine names by name in its error payload.""" + handler = AIVerbHandler() + is_valid, errors = handler.validate_config( + {"prompt": {"text": "hi"}, "post_prompt": [{"say": "x"}]} + ) + assert is_valid is False + assert "'post_prompt' must be an object" in errors + + def test_object_post_prompt_is_accepted(self) -> None: + """The shape build_config emits must stay valid.""" + handler = AIVerbHandler() + is_valid, errors = handler.validate_config( + {"prompt": {"text": "hi"}, "post_prompt": {"text": "Summarize."}} + ) + assert is_valid is True, errors + + def test_absent_post_prompt_is_accepted(self) -> None: + """post_prompt is OPTIONAL — absence must not become an error.""" + handler = AIVerbHandler() + is_valid, errors = handler.validate_config({"prompt": {"text": "hi"}}) + assert is_valid is True, errors + + def test_build_config_output_validates(self) -> None: + """The builder and the validator must agree — round-trip guard.""" + handler = AIVerbHandler() + cfg = handler.build_config( + prompt_text="You are helpful.", post_prompt="Summarize." + ) + assert cfg["post_prompt"] == {"text": "Summarize."} + is_valid, errors = handler.validate_config(cfg) + assert is_valid is True, errors diff --git a/tests/unit/core/test_swml_renderer.py b/tests/unit/core/test_swml_renderer.py index d0aa9ab3..80a0718a 100644 --- a/tests/unit/core/test_swml_renderer.py +++ b/tests/unit/core/test_swml_renderer.py @@ -14,7 +14,7 @@ import pytest import json from unittest.mock import patch, MagicMock -from typing import Dict, List, Any, Optional +from typing import Any from signalwire.core.swml_renderer import SwmlRenderer from signalwire.core.swml_service import SWMLService @@ -50,9 +50,7 @@ def test_render_swml_with_post_prompt(self) -> None: """Test SWML rendering with post prompt""" service = _make_service() result = SwmlRenderer.render_swml( - "You are helpful", - service, - post_prompt="Provide a summary" + "You are helpful", service, post_prompt="Provide a summary" ) parsed = json.loads(result) @@ -68,18 +66,14 @@ def test_render_swml_with_swaig_functions(self) -> None: "description": "Get weather information", "parameters": { "type": "object", - "properties": { - "location": {"type": "string"} - } - } + "properties": {"location": {"type": "string"}}, + }, } ] service = _make_service() result = SwmlRenderer.render_swml( - "You are helpful", - service, - swaig_functions=functions + "You are helpful", service, swaig_functions=functions ) parsed = json.loads(result) @@ -94,15 +88,11 @@ def test_render_swml_with_pom(self) -> None: """Test SWML rendering with POM format""" pom_data = [ {"title": "Section 1", "body": "Content 1"}, - {"title": "Section 2", "body": "Content 2"} + {"title": "Section 2", "body": "Content 2"}, ] service = _make_service() - result = SwmlRenderer.render_swml( - pom_data, - service, - prompt_is_pom=True - ) + result = SwmlRenderer.render_swml(pom_data, service, prompt_is_pom=True) parsed = json.loads(result) ai_verb = parsed["sections"]["main"][0] @@ -116,7 +106,7 @@ def test_render_swml_with_hooks(self) -> None: "You are helpful", service, startup_hook_url="https://example.com/startup", - hangup_hook_url="https://example.com/hangup" + hangup_hook_url="https://example.com/hangup", ) parsed = json.loads(result) @@ -131,7 +121,7 @@ def test_render_swml_with_default_webhook(self) -> None: result = SwmlRenderer.render_swml( "You are helpful", service, - default_webhook_url="https://example.com/webhook" + default_webhook_url="https://example.com/webhook", ) parsed = json.loads(result) @@ -139,19 +129,18 @@ def test_render_swml_with_default_webhook(self) -> None: assert "SWAIG" in ai_verb["ai"] assert "defaults" in ai_verb["ai"]["SWAIG"] - assert ai_verb["ai"]["SWAIG"]["defaults"]["web_hook_url"] == "https://example.com/webhook" + assert ( + ai_verb["ai"]["SWAIG"]["defaults"]["web_hook_url"] + == "https://example.com/webhook" + ) - @patch('yaml.dump') + @patch("yaml.dump") def test_render_swml_yaml_format(self, mock_yaml_dump: MagicMock) -> None: """Test SWML rendering in YAML format""" mock_yaml_dump.return_value = "version: 1.0.0\nsections:\n main: []" service = _make_service() - result = SwmlRenderer.render_swml( - "You are helpful", - service, - format="yaml" - ) + result = SwmlRenderer.render_swml("You are helpful", service, format="yaml") assert isinstance(result, str) assert "version: 1.0.0" in result @@ -181,16 +170,11 @@ def test_render_function_response_swml_basic(self) -> None: def test_render_function_response_swml_with_actions(self) -> None: """Test rendering function response SWML with actions""" - actions = [ - {"play": {"url": "test.mp3"}}, - {"hangup": {"reason": "completed"}} - ] + actions = [{"play": {"url": "test.mp3"}}, {"hangup": {"reason": "completed"}}] service = _make_service() result = SwmlRenderer.render_function_response_swml( - "Response complete", - service, - actions=actions + "Response complete", service, actions=actions ) parsed = json.loads(result) @@ -199,16 +183,16 @@ def test_render_function_response_swml_with_actions(self) -> None: # Should have play verb for response plus actions assert len(main_section) == 3 # response + 2 actions - @patch('yaml.dump') - def test_render_function_response_swml_yaml(self, mock_yaml_dump: MagicMock) -> None: + @patch("yaml.dump") + def test_render_function_response_swml_yaml( + self, mock_yaml_dump: MagicMock + ) -> None: """Test rendering function response SWML in YAML format""" mock_yaml_dump.return_value = "version: 1.0.0\nsections:\n main: []" service = _make_service() result = SwmlRenderer.render_function_response_swml( - "Hello", - service, - format="yaml" + "Hello", service, format="yaml" ) assert isinstance(result, str) @@ -272,11 +256,9 @@ def test_complete_ai_agent_swml(self) -> None: "description": "Get user account balance", "parameters": { "type": "object", - "properties": { - "account_id": {"type": "string"} - }, - "required": ["account_id"] - } + "properties": {"account_id": {"type": "string"}}, + "required": ["account_id"], + }, }, { "function": "transfer_funds", @@ -286,11 +268,11 @@ def test_complete_ai_agent_swml(self) -> None: "properties": { "from_account": {"type": "string"}, "to_account": {"type": "string"}, - "amount": {"type": "number"} + "amount": {"type": "number"}, }, - "required": ["from_account", "to_account", "amount"] - } - } + "required": ["from_account", "to_account", "amount"], + }, + }, ] service = _make_service() @@ -303,7 +285,7 @@ def test_complete_ai_agent_swml(self) -> None: startup_hook_url="https://bank.example.com/call-start", hangup_hook_url="https://bank.example.com/call-end", default_webhook_url="https://bank.example.com/functions", - params={"temperature": 0.7, "max_tokens": 150} + params={"temperature": 0.7, "max_tokens": 150}, ) parsed = json.loads(result) @@ -329,20 +311,20 @@ def test_pom_based_agent_swml(self) -> None: pom_sections: list[dict[str, Any]] = [ { "title": "Role", - "body": "You are a customer service representative for TechCorp." + "body": "You are a customer service representative for TechCorp.", }, { "title": "Guidelines", "bullets": [ "Always be polite and professional", "Ask clarifying questions when needed", - "Escalate complex issues to human agents" - ] + "Escalate complex issues to human agents", + ], }, { "title": "Available Actions", - "body": "You can help with account inquiries, technical support, and billing questions." - } + "body": "You can help with account inquiries, technical support, and billing questions.", + }, ] service = _make_service() @@ -350,7 +332,7 @@ def test_pom_based_agent_swml(self) -> None: prompt=pom_sections, service=service, prompt_is_pom=True, - post_prompt="Provide a brief summary of how you helped the customer." + post_prompt="Provide a brief summary of how you helped the customer.", ) parsed = json.loads(result) @@ -367,9 +349,7 @@ def test_function_response_workflow(self) -> None: service = _make_service() result = SwmlRenderer.render_function_response_swml( - response_text, - service, - actions=actions + response_text, service, actions=actions ) parsed = json.loads(result) @@ -378,7 +358,7 @@ def test_function_response_workflow(self) -> None: # Should have initial response plus actions assert len(main_section) == 2 - @patch('yaml.dump') + @patch("yaml.dump") def test_yaml_output_format(self, mock_yaml_dump: MagicMock) -> None: """Test YAML output format""" mock_yaml_dump.return_value = "version: 1.0.0\nsections:\n main: []" @@ -387,11 +367,8 @@ def test_yaml_output_format(self, mock_yaml_dump: MagicMock) -> None: result = SwmlRenderer.render_swml( "You are helpful", service, - swaig_functions=[{ - "function": "test", - "description": "Test function" - }], - format="yaml" + swaig_functions=[{"function": "test", "description": "Test function"}], + format="yaml", ) # Should be valid YAML diff --git a/tests/unit/core/test_swml_service.py b/tests/unit/core/test_swml_service.py index 37b36fc4..0364a4c2 100644 --- a/tests/unit/core/test_swml_service.py +++ b/tests/unit/core/test_swml_service.py @@ -13,6 +13,7 @@ import pytest import json +from pathlib import Path from typing import Any from unittest.mock import Mock, patch, MagicMock @@ -21,161 +22,152 @@ class TestSWMLServiceInitialization: """Test SWMLService initialization""" - + def test_basic_initialization(self) -> None: """Test basic service initialization""" service = SWMLService( - name="test_service", - route="/test", - host="127.0.0.1", - port=3001 + name="test_service", route="/test", host="127.0.0.1", port=3001 ) - + assert service.name == "test_service" assert service.route == "/test" assert service.host == "127.0.0.1" assert service.port == 3001 - + def test_initialization_with_defaults(self) -> None: """Test initialization with default values""" service = SWMLService(name="test_service") - + assert service.name == "test_service" assert service.route == "" # Route gets stripped of trailing slash assert service.host == "0.0.0.0" assert service.port == 3000 - + def test_initialization_with_schema_path(self) -> None: """Test initialization with schema path""" - service = SWMLService( - name="test_service", - schema_path="/path/to/schema.json" - ) - + service = SWMLService(name="test_service", schema_path="/path/to/schema.json") + assert service.name == "test_service" - assert hasattr(service, 'schema_utils') - + assert hasattr(service, "schema_utils") + def test_initialization_with_basic_auth(self) -> None: """Test initialization with basic auth""" - service = SWMLService( - name="test_service", - basic_auth=("user", "pass") - ) - + service = SWMLService(name="test_service", basic_auth=("user", "pass")) + assert service._basic_auth == ("user", "pass") class TestSWMLServiceVerbMethods: """Test SWML verb method functionality""" - + def test_add_verb_basic(self, mock_swml_service: SWMLService) -> None: """Test adding a basic verb""" result = mock_swml_service.add_verb("play", {"url": "test.mp3"}) - + # Should return boolean indicating success assert isinstance(result, bool) - + def test_add_verb_with_config(self, mock_swml_service: SWMLService) -> None: """Test adding verb with configuration""" - config = { - "url": "https://example.com/audio.mp3", - "volume": 0.8, - "loop": 3 - } - + config = {"url": "https://example.com/audio.mp3", "volume": 0.8, "loop": 3} + result = mock_swml_service.add_verb("play", config) - + # Should return boolean assert isinstance(result, bool) - + def test_add_verb_with_integer_config(self, mock_swml_service: SWMLService) -> None: """Test adding verb with integer configuration (like sleep)""" result = mock_swml_service.add_verb("sleep", 5000) - + # Should return boolean assert isinstance(result, bool) - + def test_add_verb_to_section(self, mock_swml_service: SWMLService) -> None: """Test adding verb to specific section""" # First add a section mock_swml_service.add_section("custom_section") - + # Then add verb to that section - result = mock_swml_service.add_verb_to_section("custom_section", "play", {"url": "test.mp3"}) - + result = mock_swml_service.add_verb_to_section( + "custom_section", "play", {"url": "test.mp3"} + ) + # Should return boolean assert isinstance(result, bool) class TestSWMLServiceDocumentManagement: """Test SWML document management""" - + def test_reset_document(self, mock_swml_service: SWMLService) -> None: """Test resetting the document""" # Add some verbs first mock_swml_service.add_verb("say", {"text": "Hello"}) mock_swml_service.add_verb("play", {"url": "test.mp3"}) - + # Reset document mock_swml_service.reset_document() - + # Document should be reset (we can't directly check content, but method should not raise) assert True # If we get here, reset worked - + def test_get_document(self, mock_swml_service: SWMLService) -> None: """Test getting the document""" mock_swml_service.add_verb("say", {"text": "Hello World"}) - + document = mock_swml_service.get_document() - + assert isinstance(document, dict) assert "version" in document assert "sections" in document - + def test_render_document(self, mock_swml_service: SWMLService) -> None: """Test rendering document to JSON string""" mock_swml_service.add_verb("say", {"text": "Hello"}) mock_swml_service.add_verb("play", {"url": "test.mp3"}) - + swml_json = mock_swml_service.render_document() - + assert isinstance(swml_json, str) # Should be valid JSON swml_dict = json.loads(swml_json) assert "version" in swml_dict assert "sections" in swml_dict - + def test_add_section(self, mock_swml_service: SWMLService) -> None: """Test adding a new section""" result = mock_swml_service.add_section("custom_section") - + # Should return boolean assert isinstance(result, bool) class TestSWMLServiceUtilityMethods: """Test utility methods""" - + def test_basic_properties(self, mock_swml_service: SWMLService) -> None: """Test basic property access""" assert mock_swml_service.name == "test_service" assert mock_swml_service.route == "/test" assert mock_swml_service.host == "127.0.0.1" assert mock_swml_service.port == 3001 - + def test_basic_auth_credentials(self, mock_swml_service: SWMLService) -> None: """Test getting basic auth credentials""" credentials = mock_swml_service.get_basic_auth_credentials() - + assert isinstance(credentials, tuple) assert len(credentials) == 2 assert isinstance(credentials[0], str) # username assert isinstance(credentials[1], str) # password - - def test_basic_auth_credentials_with_source(self, mock_swml_service: SWMLService) -> None: + + def test_basic_auth_credentials_with_source( + self, mock_swml_service: SWMLService + ) -> None: """Test getting basic auth credentials with source""" credentials = mock_swml_service.get_basic_auth_credentials(include_source=True) - + assert isinstance(credentials, tuple) assert len(credentials) == 3 assert isinstance(credentials[0], str) # username @@ -200,135 +192,141 @@ def test_add_hangup_verb(self, mock_swml_service: SWMLService) -> None: def test_add_ai_verb(self, mock_swml_service: SWMLService) -> None: """Test adding AI verb via add_verb""" - result = mock_swml_service.add_verb("ai", { - "prompt": { - "text": "You are a helpful assistant" + result = mock_swml_service.add_verb( + "ai", + { + "prompt": {"text": "You are a helpful assistant"}, + "post_prompt": {"text": "Thank you for using our service"}, }, - "post_prompt": { - "text": "Thank you for using our service" - } - }) + ) assert isinstance(result, bool) class TestSWMLServiceErrorHandling: """Test error handling and edge cases""" - + def test_add_verb_with_none_config(self, mock_swml_service: SWMLService) -> None: """Test adding verb with None configuration""" result = mock_swml_service.add_verb("hangup", None) # type: ignore[arg-type] # intentional invalid input # Should return boolean (likely False due to invalid config) assert isinstance(result, bool) - + def test_add_verb_with_empty_config(self, mock_swml_service: SWMLService) -> None: """Test adding verb with empty configuration""" result = mock_swml_service.add_verb("hangup", {}) - + # Should return boolean assert isinstance(result, bool) - + def test_invalid_verb_name(self, mock_swml_service: SWMLService) -> None: """Test handling of invalid verb names""" result = mock_swml_service.add_verb("", {"test": "value"}) - + # Should return boolean (likely False due to invalid verb) assert isinstance(result, bool) - - def test_add_verb_to_nonexistent_section(self, mock_swml_service: SWMLService) -> None: + + def test_add_verb_to_nonexistent_section( + self, mock_swml_service: SWMLService + ) -> None: """Test adding verb to non-existent section""" - result = mock_swml_service.add_verb_to_section("nonexistent", "play", {"url": "test.mp3"}) - + result = mock_swml_service.add_verb_to_section( + "nonexistent", "play", {"url": "test.mp3"} + ) + # Should return boolean (likely False) assert isinstance(result, bool) class TestSWMLServiceRouting: """Test routing and callback functionality""" - + def test_register_routing_callback(self, mock_swml_service: SWMLService) -> None: """Test registering routing callback""" + def test_callback(request: Any, data: dict[str, Any]) -> str | None: return "test_response" - + # Should not raise error mock_swml_service.register_routing_callback(test_callback, "/test") - + # Callback should be registered assert "/test" in mock_swml_service._routing_callbacks - + def test_extract_sip_username(self) -> None: """Test SIP username extraction""" request_body = { "from": "sip:testuser@example.com", - "to": "sip:destination@example.com" + "to": "sip:destination@example.com", } - + username = SWMLService.extract_sip_username(request_body) - + # Should extract username from SIP URI assert isinstance(username, (str, type(None))) class TestSWMLServiceIntegration: """Test integration functionality""" - + def test_as_router(self, mock_swml_service: SWMLService) -> None: """Test getting as FastAPI router""" router = mock_swml_service.as_router() - + # Should return APIRouter instance assert router is not None - assert hasattr(router, 'routes') - + assert hasattr(router, "routes") + def test_on_request_handling(self, mock_swml_service: SWMLService) -> None: """Test request handling""" test_data = {"call_id": "test-123", "from": "+1234567890"} - + # Should not raise error result = mock_swml_service.on_request(test_data) - + # Result can be None or dict assert result is None or isinstance(result, dict) - + def test_manual_proxy_url_setting(self, mock_swml_service: SWMLService) -> None: """Test manual proxy URL setting""" proxy_url = "https://example.ngrok.io" - + # Should not raise error mock_swml_service.manual_set_proxy_url(proxy_url) - + # Should set the proxy URL assert mock_swml_service._proxy_url_base == proxy_url assert mock_swml_service._proxy_detection_done is True - + def test_verb_handler_registry(self, mock_swml_service: SWMLService) -> None: """Test verb handler registry""" # Should have verb registry - assert hasattr(mock_swml_service, 'verb_registry') + assert hasattr(mock_swml_service, "verb_registry") assert mock_swml_service.verb_registry is not None - + def test_schema_utils_integration(self, mock_swml_service: SWMLService) -> None: """Test schema utilities integration""" # Should have schema utils - assert hasattr(mock_swml_service, 'schema_utils') - + assert hasattr(mock_swml_service, "schema_utils") + if mock_swml_service.schema_utils: # If schema utils available, should be able to get verb names verb_names = mock_swml_service.schema_utils.get_all_verb_names() assert isinstance(verb_names, list) - - def test_json_serialization_of_document(self, mock_swml_service: SWMLService) -> None: + + def test_json_serialization_of_document( + self, mock_swml_service: SWMLService + ) -> None: """Test JSON serialization of SWML document""" mock_swml_service.add_verb("say", {"text": "Test message"}) - + document = mock_swml_service.get_document() - + # Should be JSON serializable json_str = json.dumps(document) assert isinstance(json_str, str) - + # Should be deserializable parsed = json.loads(json_str) assert isinstance(parsed, dict) @@ -350,10 +348,15 @@ def test_verb_methods_cache_populated(self, mock_swml_service: SWMLService) -> N # Even with schema_validation=False, the schema is still loaded and # verb names are extracted, so the cache should not be empty when the # real schema file is found. - if mock_swml_service.schema_utils and mock_swml_service.schema_utils.get_all_verb_names(): + if ( + mock_swml_service.schema_utils + and mock_swml_service.schema_utils.get_all_verb_names() + ): assert len(mock_swml_service._verb_methods_cache) > 0 - def test_known_verbs_exist_as_attributes(self, mock_swml_service: SWMLService) -> None: + def test_known_verbs_exist_as_attributes( + self, mock_swml_service: SWMLService + ) -> None: """Common SWML verbs should be accessible as attributes after init.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() if not verb_names: @@ -370,7 +373,9 @@ def test_verb_method_is_callable(self, mock_swml_service: SWMLService) -> None: method = getattr(mock_swml_service, vn) assert callable(method), f"Verb '{vn}' attribute should be callable" - def test_verb_method_adds_verb_to_document(self, mock_swml_service: SWMLService) -> None: + def test_verb_method_adds_verb_to_document( + self, mock_swml_service: SWMLService + ) -> None: """Calling a dynamically created verb method should add the verb to the document.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() # Pick a verb that isn't 'sleep' (which has special handling) @@ -401,7 +406,9 @@ def test_verb_method_passes_kwargs(self, mock_swml_service: SWMLService) -> None config = doc["sections"]["main"][0][vn] assert config.get("some_key") == "some_value" - def test_verb_method_strips_none_kwargs(self, mock_swml_service: SWMLService) -> None: + def test_verb_method_strips_none_kwargs( + self, mock_swml_service: SWMLService + ) -> None: """None-valued kwargs should be stripped from the config.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() non_sleep = [v for v in verb_names if v != "sleep"] @@ -434,7 +441,9 @@ def test_sleep_verb_takes_duration(self, mock_swml_service: SWMLService) -> None doc = mock_swml_service.get_document() assert {"sleep": 5000} in doc["sections"]["main"] - def test_sleep_verb_raises_without_duration(self, mock_swml_service: SWMLService) -> None: + def test_sleep_verb_raises_without_duration( + self, mock_swml_service: SWMLService + ) -> None: """Sleep verb method should raise TypeError when no duration given.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() if "sleep" not in verb_names: @@ -442,7 +451,9 @@ def test_sleep_verb_raises_without_duration(self, mock_swml_service: SWMLService with pytest.raises(TypeError, match="missing required argument"): mock_swml_service.sleep() - def test_sleep_verb_accepts_kwargs_fallback(self, mock_swml_service: SWMLService) -> None: + def test_sleep_verb_accepts_kwargs_fallback( + self, mock_swml_service: SWMLService + ) -> None: """Sleep verb should accept value via kwargs when duration is None.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() if "sleep" not in verb_names: @@ -564,7 +575,7 @@ def test_getattr_sleep_verb(self) -> None: pytest.skip("sleep verb not in schema") # Clear sleep from cache to force __getattr__ path service._verb_methods_cache.pop("sleep", None) - sleep_method = getattr(service, "sleep") + sleep_method = service.sleep assert callable(sleep_method) service.reset_document() sleep_method(duration=2000) @@ -584,7 +595,9 @@ def test_getattr_no_schema_raises(self) -> None: with pytest.raises(AttributeError, match="no schema available"): _ = service.some_verb - def test_getattr_error_message_includes_class_name(self, mock_swml_service: SWMLService) -> None: + def test_getattr_error_message_includes_class_name( + self, mock_swml_service: SWMLService + ) -> None: """The AttributeError message should include the class name.""" with pytest.raises(AttributeError, match="SWMLService"): _ = mock_swml_service.nonexistent_xyz_123 @@ -593,7 +606,11 @@ def test_getattr_error_message_includes_class_name(self, mock_swml_service: SWML class TestProxyDetection: """Test _detect_proxy_from_request() with various header combinations.""" - def _make_request(self, headers: dict[str, str] | None = None, url: str = "http://127.0.0.1:3001/test") -> Mock: + def _make_request( + self, + headers: dict[str, str] | None = None, + url: str = "http://127.0.0.1:3001/test", + ) -> Mock: """Helper to create a mock FastAPI request.""" request = Mock() _headers = headers or {} @@ -606,46 +623,62 @@ def _make_request(self, headers: dict[str, str] | None = None, url: str = "http: def test_x_forwarded_host_and_proto(self, mock_swml_service: SWMLService) -> None: """X-Forwarded-Host + X-Forwarded-Proto should set proxy_url_base.""" mock_swml_service._proxy_url_base = None - request = self._make_request(headers={ - "X-Forwarded-Host": "example.ngrok.io", - "X-Forwarded-Proto": "https", - }) + request = self._make_request( + headers={ + "X-Forwarded-Host": "example.ngrok.io", + "X-Forwarded-Proto": "https", + } + ) mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "https://example.ngrok.io" - def test_x_forwarded_host_default_proto(self, mock_swml_service: SWMLService) -> None: + def test_x_forwarded_host_default_proto( + self, mock_swml_service: SWMLService + ) -> None: """When X-Forwarded-Proto is missing, default to http.""" mock_swml_service._proxy_url_base = None - request = self._make_request(headers={ - "X-Forwarded-Host": "proxy.example.com", - }) + request = self._make_request( + headers={ + "X-Forwarded-Host": "proxy.example.com", + } + ) mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "http://proxy.example.com" def test_rfc7239_forwarded_header(self, mock_swml_service: SWMLService) -> None: """RFC 7239 Forwarded header should be parsed correctly.""" mock_swml_service._proxy_url_base = None - request = self._make_request(headers={ - "Forwarded": 'for=192.0.2.60;host=example.com;proto=https', - }) + request = self._make_request( + headers={ + "Forwarded": "for=192.0.2.60;host=example.com;proto=https", + } + ) mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "https://example.com" - def test_rfc7239_forwarded_header_http_default(self, mock_swml_service: SWMLService) -> None: + def test_rfc7239_forwarded_header_http_default( + self, mock_swml_service: SWMLService + ) -> None: """RFC 7239 Forwarded header without proto should default to http.""" mock_swml_service._proxy_url_base = None - request = self._make_request(headers={ - "Forwarded": 'for=10.0.0.1;host=myproxy.example.com', - }) + request = self._make_request( + headers={ + "Forwarded": "for=10.0.0.1;host=myproxy.example.com", + } + ) mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "http://myproxy.example.com" - def test_rfc7239_forwarded_no_host_ignored(self, mock_swml_service: SWMLService) -> None: + def test_rfc7239_forwarded_no_host_ignored( + self, mock_swml_service: SWMLService + ) -> None: """Forwarded header without host= should not set proxy_url_base from that header.""" mock_swml_service._proxy_url_base = None - request = self._make_request(headers={ - "Forwarded": 'for=10.0.0.1;proto=https', - }) + request = self._make_request( + headers={ + "Forwarded": "for=10.0.0.1;proto=https", + } + ) mock_swml_service._detect_proxy_from_request(request) # Without a host, the Forwarded header can't set the proxy URL, so # it falls through to other detection methods. @@ -654,44 +687,60 @@ def test_rfc7239_forwarded_no_host_ignored(self, mock_swml_service: SWMLService) def test_x_original_host(self, mock_swml_service: SWMLService) -> None: """X-Original-Host should be used when other headers are absent.""" mock_swml_service._proxy_url_base = None - request = self._make_request(headers={ - "X-Original-Host": "original.example.com", - }) + request = self._make_request( + headers={ + "X-Original-Host": "original.example.com", + } + ) mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "http://original.example.com" - def test_host_header_with_external_host(self, mock_swml_service: SWMLService) -> None: + def test_host_header_with_external_host( + self, mock_swml_service: SWMLService + ) -> None: """Host header pointing to an external host should trigger proxy detection.""" mock_swml_service._proxy_url_base = None - request = self._make_request(headers={ - "Host": "external.example.com", - }) + request = self._make_request( + headers={ + "Host": "external.example.com", + } + ) mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "http://external.example.com" - def test_host_header_with_local_host_ignored(self, mock_swml_service: SWMLService) -> None: + def test_host_header_with_local_host_ignored( + self, mock_swml_service: SWMLService + ) -> None: """Host header pointing to local host should not set proxy_url_base.""" mock_swml_service._proxy_url_base = None - request = self._make_request(headers={ - "Host": f"127.0.0.1:{mock_swml_service.port}", - }) + request = self._make_request( + headers={ + "Host": f"127.0.0.1:{mock_swml_service.port}", + } + ) mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base is None - def test_no_proxy_headers_returns_none(self, mock_swml_service: SWMLService) -> None: + def test_no_proxy_headers_returns_none( + self, mock_swml_service: SWMLService + ) -> None: """With no proxy headers and a local URL, proxy_url_base should remain None.""" mock_swml_service._proxy_url_base = None request = self._make_request(headers={}) mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base is None - def test_already_set_proxy_not_overridden(self, mock_swml_service: SWMLService) -> None: + def test_already_set_proxy_not_overridden( + self, mock_swml_service: SWMLService + ) -> None: """If proxy_url_base is already set, it should not be overridden.""" mock_swml_service._proxy_url_base = "https://already.set.com" - request = self._make_request(headers={ - "X-Forwarded-Host": "new.proxy.com", - "X-Forwarded-Proto": "https", - }) + request = self._make_request( + headers={ + "X-Forwarded-Host": "new.proxy.com", + "X-Forwarded-Proto": "https", + } + ) mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "https://already.set.com" @@ -707,44 +756,47 @@ def test_transparent_proxy_detection(self, mock_swml_service: SWMLService) -> No mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "https://external.proxy.com:8443" - def test_x_forwarded_for_without_host_does_not_set_proxy(self, mock_swml_service: SWMLService) -> None: - """X-Forwarded-For without host info should not set proxy_url_base. - - Note: The production code has a structlog parameter conflict ('message' - is used as both positional and keyword), but we verify the method - doesn't set the proxy URL by catching the TypeError. - """ + def test_x_forwarded_for_without_host_does_not_set_proxy( + self, mock_swml_service: SWMLService + ) -> None: + """X-Forwarded-For without host info should not set proxy_url_base.""" mock_swml_service._proxy_url_base = None - request = self._make_request(headers={ - "X-Forwarded-For": "10.0.0.1, 10.0.0.2", - }) - try: - mock_swml_service._detect_proxy_from_request(request) - except TypeError: - # Known structlog 'message' parameter conflict in production code - pass + request = self._make_request( + headers={ + "X-Forwarded-For": "10.0.0.1, 10.0.0.2", + } + ) + mock_swml_service._detect_proxy_from_request(request) # Cannot determine public URL from X-Forwarded-For alone assert mock_swml_service._proxy_url_base is None - def test_forwarded_header_parse_error_handled(self, mock_swml_service: SWMLService) -> None: + def test_forwarded_header_parse_error_handled( + self, mock_swml_service: SWMLService + ) -> None: """Malformed Forwarded header should not raise — just log a warning.""" mock_swml_service._proxy_url_base = None # Provide a Forwarded value that will trigger the parsing path but has # no host= part, so the code falls through. - request = self._make_request(headers={ - "Forwarded": ";;completely;broken;value;", - }) + request = self._make_request( + headers={ + "Forwarded": ";;completely;broken;value;", + } + ) # Should not raise mock_swml_service._detect_proxy_from_request(request) - def test_multiple_proxy_hops_x_forwarded(self, mock_swml_service: SWMLService) -> None: + def test_multiple_proxy_hops_x_forwarded( + self, mock_swml_service: SWMLService + ) -> None: """With multiple hops, the first X-Forwarded-Host value should win.""" mock_swml_service._proxy_url_base = None - request = self._make_request(headers={ - "X-Forwarded-Host": "first-hop.example.com", - "X-Forwarded-Proto": "https", - "X-Forwarded-For": "10.0.0.1, 10.0.0.2, 10.0.0.3", - }) + request = self._make_request( + headers={ + "X-Forwarded-Host": "first-hop.example.com", + "X-Forwarded-Proto": "https", + "X-Forwarded-For": "10.0.0.1, 10.0.0.2, 10.0.0.3", + } + ) mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "https://first-hop.example.com" @@ -787,8 +839,14 @@ def test_serve_custom_host_port(self, mock_uvicorn_module: MagicMock) -> None: assert call_kwargs[1]["port"] == 9999 @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_with_ssl(self, mock_uvicorn_module: MagicMock) -> None: + def test_serve_with_ssl( + self, mock_uvicorn_module: MagicMock, tmp_path: Path + ) -> None: """serve() with SSL should pass cert/key to uvicorn.run.""" + cert = tmp_path / "cert.pem" + key = tmp_path / "key.pem" + cert.write_text("cert") + key.write_text("key") service = SWMLService( name="serve_ssl", route="/", @@ -796,18 +854,22 @@ def test_serve_with_ssl(self, mock_uvicorn_module: MagicMock) -> None: port=443, schema_validation=False, ) - # We need to make validate_ssl_config return success - service.security.validate_ssl_config = Mock(return_value=(True, None)) # type: ignore[method-assign] # mock service.domain = "example.com" with patch.dict("sys.modules", {"uvicorn": mock_uvicorn_module}): - service.serve(ssl_enabled=True, ssl_cert="/path/cert.pem", ssl_key="/path/key.pem") + service.serve(ssl_enabled=True, ssl_cert=str(cert), ssl_key=str(key)) call_kwargs = mock_uvicorn_module.run.call_args - assert call_kwargs[1].get("ssl_certfile") == "/path/cert.pem" - assert call_kwargs[1].get("ssl_keyfile") == "/path/key.pem" + assert call_kwargs[1].get("ssl_certfile") == str(cert) + assert call_kwargs[1].get("ssl_keyfile") == str(key) @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_ssl_invalid_config_disables_ssl(self, mock_uvicorn_module: MagicMock) -> None: - """serve() should disable SSL when validation fails.""" + def test_serve_ssl_invalid_config_refuses_to_start( + self, mock_uvicorn_module: MagicMock + ) -> None: + """serve() must FAIL when TLS is requested but unconfigurable. + + Clearing ssl_enabled and running uvicorn plain would hand the operator + a cleartext listener they believe is encrypted. + """ service = SWMLService( name="serve_ssl_invalid", route="/", @@ -815,13 +877,52 @@ def test_serve_ssl_invalid_config_disables_ssl(self, mock_uvicorn_module: MagicM port=443, schema_validation=False, ) - service.security.validate_ssl_config = Mock(return_value=(False, "cert not found")) # type: ignore[method-assign] # mock - with patch.dict("sys.modules", {"uvicorn": mock_uvicorn_module}): + with ( + patch.dict("sys.modules", {"uvicorn": mock_uvicorn_module}), + pytest.raises(RuntimeError, match="TLS configuration is invalid"), + ): service.serve(ssl_enabled=True) - # SSL should have been disabled due to invalid config - assert service.ssl_enabled is False - call_kwargs = mock_uvicorn_module.run.call_args - assert "ssl_certfile" not in call_kwargs[1] + mock_uvicorn_module.run.assert_not_called() + + @patch("signalwire.core.swml_service.uvicorn", create=True) + def test_serve_ssl_missing_cert_file_refuses_to_start( + self, mock_uvicorn_module: MagicMock, tmp_path: Path + ) -> None: + """A cert PATH that does not exist is still a refusal, not a downgrade.""" + service = SWMLService( + name="serve_ssl_absent_cert", + route="/", + host="0.0.0.0", + port=443, + schema_validation=False, + ) + with ( + patch.dict("sys.modules", {"uvicorn": mock_uvicorn_module}), + pytest.raises(RuntimeError, match="certificate file not found"), + ): + service.serve( + ssl_enabled=True, + ssl_cert=str(tmp_path / "absent.pem"), + ssl_key=str(tmp_path / "absent.key"), + ) + mock_uvicorn_module.run.assert_not_called() + + @patch("signalwire.core.swml_service.uvicorn", create=True) + def test_serve_without_ssl_still_serves_plain_http( + self, mock_uvicorn_module: MagicMock + ) -> None: + """Scope control: SSL off must keep working, with no ssl kwargs.""" + service = SWMLService( + name="serve_plain", + route="/", + host="0.0.0.0", + port=3000, + schema_validation=False, + ) + with patch.dict("sys.modules", {"uvicorn": mock_uvicorn_module}): + service.serve(ssl_enabled=False) + mock_uvicorn_module.run.assert_called_once() + assert "ssl_certfile" not in mock_uvicorn_module.run.call_args[1] @patch("signalwire.core.swml_service.uvicorn", create=True) def test_serve_creates_fastapi_app(self, mock_uvicorn_module: MagicMock) -> None: @@ -858,7 +959,9 @@ def test_serve_reuses_existing_app(self, mock_uvicorn_module: MagicMock) -> None assert service._app is app_first @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_prints_startup_info(self, mock_uvicorn_module: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: + def test_serve_prints_startup_info( + self, mock_uvicorn_module: MagicMock, capsys: pytest.CaptureFixture[str] + ) -> None: """serve() should print user-friendly startup info.""" service = SWMLService( name="serve_print", @@ -874,8 +977,14 @@ def test_serve_prints_startup_info(self, mock_uvicorn_module: MagicMock, capsys: assert "Basic Auth" in captured.out @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_ssl_without_domain_warns(self, mock_uvicorn_module: MagicMock) -> None: + def test_serve_ssl_without_domain_warns( + self, mock_uvicorn_module: MagicMock, tmp_path: Path + ) -> None: """serve() with SSL but no domain should still proceed (with warning).""" + cert = tmp_path / "cert.pem" + key = tmp_path / "key.pem" + cert.write_text("cert") + key.write_text("key") service = SWMLService( name="serve_no_domain", route="/", @@ -883,16 +992,17 @@ def test_serve_ssl_without_domain_warns(self, mock_uvicorn_module: MagicMock) -> port=443, schema_validation=False, ) - service.security.validate_ssl_config = Mock(return_value=(True, None)) # type: ignore[method-assign] # mock service.domain = None with patch.dict("sys.modules", {"uvicorn": mock_uvicorn_module}): - service.serve(ssl_enabled=True, ssl_cert="/cert.pem", ssl_key="/key.pem") + service.serve(ssl_enabled=True, ssl_cert=str(cert), ssl_key=str(key)) # Should still call uvicorn.run with SSL params call_kwargs = mock_uvicorn_module.run.call_args - assert call_kwargs[1].get("ssl_certfile") == "/cert.pem" + assert call_kwargs[1].get("ssl_certfile") == str(cert) @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_with_routing_callbacks(self, mock_uvicorn_module: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: + def test_serve_with_routing_callbacks( + self, mock_uvicorn_module: MagicMock, capsys: pytest.CaptureFixture[str] + ) -> None: """serve() should print callback endpoint info when callbacks registered.""" service = SWMLService( name="serve_callbacks", @@ -931,27 +1041,35 @@ def test_add_section_returns_true(self, mock_swml_service: SWMLService) -> None: result = mock_swml_service.add_section("my_section") assert result is True - def test_add_duplicate_section_returns_false(self, mock_swml_service: SWMLService) -> None: + def test_add_duplicate_section_returns_false( + self, mock_swml_service: SWMLService + ) -> None: """Adding a section that already exists should return False.""" mock_swml_service.reset_document() mock_swml_service.add_section("dup_section") result = mock_swml_service.add_section("dup_section") assert result is False - def test_main_section_exists_by_default(self, mock_swml_service: SWMLService) -> None: + def test_main_section_exists_by_default( + self, mock_swml_service: SWMLService + ) -> None: """The 'main' section should exist by default in a new document.""" mock_swml_service.reset_document() doc = mock_swml_service.get_document() assert "main" in doc["sections"] - def test_add_section_creates_empty_list(self, mock_swml_service: SWMLService) -> None: + def test_add_section_creates_empty_list( + self, mock_swml_service: SWMLService + ) -> None: """A newly added section should be an empty list.""" mock_swml_service.reset_document() mock_swml_service.add_section("empty_section") doc = mock_swml_service.get_document() assert doc["sections"]["empty_section"] == [] - def test_add_duplicate_main_returns_false(self, mock_swml_service: SWMLService) -> None: + def test_add_duplicate_main_returns_false( + self, mock_swml_service: SWMLService + ) -> None: """Trying to add 'main' again should return False.""" mock_swml_service.reset_document() result = mock_swml_service.add_section("main") @@ -966,7 +1084,9 @@ def test_add_verb_to_named_section(self, mock_swml_service: SWMLService) -> None doc = mock_swml_service.get_document() assert {"sleep": 1000} in doc["sections"]["secondary"] - def test_add_verb_to_section_auto_creates_section(self, mock_swml_service: SWMLService) -> None: + def test_add_verb_to_section_auto_creates_section( + self, mock_swml_service: SWMLService + ) -> None: """add_verb_to_section should auto-create the section if it does not exist.""" mock_swml_service.reset_document() result = mock_swml_service.add_verb_to_section("auto_created", "sleep", 500) @@ -975,14 +1095,19 @@ def test_add_verb_to_section_auto_creates_section(self, mock_swml_service: SWMLS assert "auto_created" in doc["sections"] assert {"sleep": 500} in doc["sections"]["auto_created"] - def test_add_verb_to_section_invalid_config_type(self, mock_swml_service: SWMLService) -> None: + def test_add_verb_to_section_invalid_config_type( + self, mock_swml_service: SWMLService + ) -> None: """add_verb_to_section with non-dict non-sleep config should return False.""" mock_swml_service.reset_document() mock_swml_service.add_section("bad_section") - result = mock_swml_service.add_verb_to_section("bad_section", "play", "not_a_dict") # type: ignore[arg-type] # intentional invalid input + add_verb = mock_swml_service.add_verb_to_section + result = add_verb("bad_section", "play", "not_a_dict") # type: ignore[arg-type] # intentional invalid input assert result is False - def test_multiple_sections_in_document(self, mock_swml_service: SWMLService) -> None: + def test_multiple_sections_in_document( + self, mock_swml_service: SWMLService + ) -> None: """Multiple sections should all appear in the document.""" mock_swml_service.reset_document() mock_swml_service.add_section("alpha") @@ -992,7 +1117,9 @@ def test_multiple_sections_in_document(self, mock_swml_service: SWMLService) -> for name in ("main", "alpha", "beta", "gamma"): assert name in doc["sections"] - def test_verbs_in_different_sections_independent(self, mock_swml_service: SWMLService) -> None: + def test_verbs_in_different_sections_independent( + self, mock_swml_service: SWMLService + ) -> None: """Verbs added to different sections should remain independent.""" mock_swml_service.reset_document() mock_swml_service.add_section("section_a") @@ -1016,7 +1143,9 @@ def test_section_ordering_preserved(self, mock_swml_service: SWMLService) -> Non assert section_keys[0] == "main" assert section_keys[1:] == names - def test_add_verb_main_section_by_default(self, mock_swml_service: SWMLService) -> None: + def test_add_verb_main_section_by_default( + self, mock_swml_service: SWMLService + ) -> None: """add_verb should add to main section.""" mock_swml_service.reset_document() mock_swml_service.add_verb("sleep", 1234) @@ -1032,7 +1161,9 @@ def test_reset_clears_all_sections(self, mock_swml_service: SWMLService) -> None assert "custom" not in doc["sections"] assert doc["sections"]["main"] == [] - def test_render_document_includes_all_sections(self, mock_swml_service: SWMLService) -> None: + def test_render_document_includes_all_sections( + self, mock_swml_service: SWMLService + ) -> None: """render_document should serialize all sections.""" mock_swml_service.reset_document() mock_swml_service.add_section("extra") @@ -1042,7 +1173,9 @@ def test_render_document_includes_all_sections(self, mock_swml_service: SWMLServ assert "extra" in parsed["sections"] assert {"sleep": 42} in parsed["sections"]["extra"] - def test_add_verb_non_dict_config_returns_false(self, mock_swml_service: SWMLService) -> None: + def test_add_verb_non_dict_config_returns_false( + self, mock_swml_service: SWMLService + ) -> None: """add_verb with a non-dict, non-sleep-int config returns False.""" mock_swml_service.reset_document() result = mock_swml_service.add_verb("play", 42) @@ -1056,7 +1189,9 @@ def test_add_verb_sleep_int_config(self, mock_swml_service: SWMLService) -> None doc = mock_swml_service.get_document() assert {"sleep": 3000} in doc["sections"]["main"] - def test_add_verb_to_section_sleep_int_config(self, mock_swml_service: SWMLService) -> None: + def test_add_verb_to_section_sleep_int_config( + self, mock_swml_service: SWMLService + ) -> None: """add_verb_to_section with sleep and int config should succeed.""" mock_swml_service.reset_document() mock_swml_service.add_section("timers") @@ -1094,6 +1229,7 @@ def test_valid_basic_auth(self) -> None: schema_validation=False, ) import base64 + creds = base64.b64encode(b"admin:secret").decode() request = self._make_request_with_auth(f"Basic {creds}") assert service._check_basic_auth(request) is True @@ -1109,6 +1245,7 @@ def test_wrong_password_returns_false(self) -> None: schema_validation=False, ) import base64 + creds = base64.b64encode(b"admin:wrong").decode() request = self._make_request_with_auth(f"Basic {creds}") assert service._check_basic_auth(request) is False @@ -1124,6 +1261,7 @@ def test_wrong_username_returns_false(self) -> None: schema_validation=False, ) import base64 + creds = base64.b64encode(b"hacker:secret").decode() request = self._make_request_with_auth(f"Basic {creds}") assert service._check_basic_auth(request) is False @@ -1398,7 +1536,9 @@ def test_build_webhook_url(self) -> None: class TestFullValidationEnabled: """Test full_validation_enabled property.""" - def test_full_validation_with_schema_utils(self, mock_swml_service: SWMLService) -> None: + def test_full_validation_with_schema_utils( + self, mock_swml_service: SWMLService + ) -> None: """Property should delegate to schema_utils.""" result = mock_swml_service.full_validation_enabled assert isinstance(result, bool) @@ -1463,7 +1603,9 @@ def test_register_verb_handler(self, mock_swml_service: SWMLService) -> None: mock_handler.verb_name = "custom_verb" mock_swml_service.verb_registry.register_handler = Mock() # type: ignore[method-assign] # mock mock_swml_service.register_verb_handler(mock_handler) - mock_swml_service.verb_registry.register_handler.assert_called_once_with(mock_handler) + mock_swml_service.verb_registry.register_handler.assert_called_once_with( + mock_handler + ) class TestCreateEmptyDocument: @@ -1569,12 +1711,10 @@ def test_empty_string_does_nothing(self, mock_swml_service: SWMLService) -> None # Additional test classes for expanded coverage # --------------------------------------------------------------------------- -import asyncio import base64 -import copy import os -from fastapi import FastAPI, Request, Response +from fastapi import FastAPI from starlette.testclient import TestClient from signalwire.utils.schema_utils import SchemaValidationError @@ -1603,8 +1743,12 @@ class TestHandleRequestGET: def test_get_returns_swml_document(self) -> None: """GET with valid auth should return the SWML document.""" svc = SWMLService( - name="hr_get", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="hr_get", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) client = _build_test_client(svc) resp = client.get("/", headers=_auth_header("u", "p")) @@ -1616,8 +1760,12 @@ def test_get_returns_swml_document(self) -> None: def test_get_without_auth_returns_401(self) -> None: """GET without auth should return 401.""" svc = SWMLService( - name="hr_noauth", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="hr_noauth", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) client = _build_test_client(svc) resp = client.get("/") @@ -1626,8 +1774,12 @@ def test_get_without_auth_returns_401(self) -> None: def test_get_with_wrong_auth_returns_401(self) -> None: """GET with wrong credentials should return 401.""" svc = SWMLService( - name="hr_wrongauth", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="hr_wrongauth", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) client = _build_test_client(svc) resp = client.get("/", headers=_auth_header("u", "wrong")) @@ -1640,8 +1792,12 @@ class TestHandleRequestPOST: def test_post_with_empty_body(self) -> None: """POST with empty body should return SWML document.""" svc = SWMLService( - name="hr_post_empty", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="hr_post_empty", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) client = _build_test_client(svc) resp = client.post("/", headers=_auth_header("u", "p")) @@ -1651,12 +1807,17 @@ def test_post_with_empty_body(self) -> None: def test_post_with_json_body(self) -> None: """POST with JSON body should still return SWML document.""" svc = SWMLService( - name="hr_post_json", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="hr_post_json", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) client = _build_test_client(svc) resp = client.post( - "/", json={"call": {"to": "sip:test@example.com"}}, + "/", + json={"call": {"to": "sip:test@example.com"}}, headers=_auth_header("u", "p"), ) assert resp.status_code == 200 @@ -1664,12 +1825,17 @@ def test_post_with_json_body(self) -> None: def test_post_with_invalid_json_body(self) -> None: """POST with invalid JSON should still return SWML (body parse error handled).""" svc = SWMLService( - name="hr_post_bad", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="hr_post_bad", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) client = _build_test_client(svc) resp = client.post( - "/", content=b"not-valid-json", + "/", + content=b"not-valid-json", headers={**_auth_header("u", "p"), "content-type": "application/json"}, ) assert resp.status_code == 200 @@ -1681,8 +1847,12 @@ class TestHandleRequestOnRequestModifications: def test_on_request_returns_modifications(self) -> None: """When on_request returns a dict, those modifications should be applied.""" svc = SWMLService( - name="hr_mod", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="hr_mod", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) # Override on_request to return modifications svc.on_request = lambda data, cb_path: {"version": "2.0.0"} # type: ignore[method-assign,misc,assignment] # mock override @@ -1695,8 +1865,12 @@ def test_on_request_returns_modifications(self) -> None: def test_on_request_returns_none_no_modification(self) -> None: """When on_request returns None, the original document should be returned.""" svc = SWMLService( - name="hr_nomod", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="hr_nomod", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) svc.on_request = lambda data, cb_path: None # type: ignore[method-assign,misc,assignment] # mock override client = _build_test_client(svc) @@ -1712,8 +1886,12 @@ class TestHandleRequestRoutingCallback: def test_routing_callback_redirect(self) -> None: """Routing callback returning a route should produce a 307 redirect.""" svc = SWMLService( - name="hr_cb_redir", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="hr_cb_redir", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) def my_callback(request: Any, body: dict[str, Any]) -> str | None: @@ -1722,7 +1900,8 @@ def my_callback(request: Any, body: dict[str, Any]) -> str | None: svc.register_routing_callback(my_callback, "/sip") client = _build_test_client(svc) resp = client.post( - "/sip", json={"call": {"to": "sip:test@example.com"}}, + "/sip", + json={"call": {"to": "sip:test@example.com"}}, headers=_auth_header("u", "p"), follow_redirects=False, ) @@ -1732,8 +1911,12 @@ def my_callback(request: Any, body: dict[str, Any]) -> str | None: def test_routing_callback_returns_none_continues(self) -> None: """Routing callback returning None should produce normal SWML response.""" svc = SWMLService( - name="hr_cb_none", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="hr_cb_none", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) def my_callback(request: Any, body: dict[str, Any]) -> str | None: @@ -1742,7 +1925,8 @@ def my_callback(request: Any, body: dict[str, Any]) -> str | None: svc.register_routing_callback(my_callback, "/sip") client = _build_test_client(svc) resp = client.post( - "/sip", json={"call": {"to": "sip:test@example.com"}}, + "/sip", + json={"call": {"to": "sip:test@example.com"}}, headers=_auth_header("u", "p"), ) assert resp.status_code == 200 @@ -1751,8 +1935,12 @@ def my_callback(request: Any, body: dict[str, Any]) -> str | None: def test_routing_callback_exception_handled(self) -> None: """Routing callback that raises should be caught; normal SWML returned.""" svc = SWMLService( - name="hr_cb_err", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="hr_cb_err", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) def bad_callback(request: Any, body: dict[str, Any]) -> str | None: @@ -1761,7 +1949,8 @@ def bad_callback(request: Any, body: dict[str, Any]) -> str | None: svc.register_routing_callback(bad_callback, "/sip") client = _build_test_client(svc) resp = client.post( - "/sip", json={"call": {"to": "sip:test@example.com"}}, + "/sip", + json={"call": {"to": "sip:test@example.com"}}, headers=_auth_header("u", "p"), ) assert resp.status_code == 200 @@ -1773,8 +1962,12 @@ class TestAsRouterWithCallbacks: def test_as_router_registers_callback_endpoints(self) -> None: """as_router should register endpoints for each routing callback.""" svc = SWMLService( - name="ar_cb", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="ar_cb", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) svc.register_routing_callback(lambda r, b: None, "/sip") router = svc.as_router() @@ -1784,8 +1977,12 @@ def test_as_router_registers_callback_endpoints(self) -> None: def test_as_router_skips_root_callback(self) -> None: """as_router should skip root '/' callback since root is always registered.""" svc = SWMLService( - name="ar_root_cb", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="ar_root_cb", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) svc.register_routing_callback(lambda r, b: None, "/") router = svc.as_router() @@ -1796,8 +1993,12 @@ def test_as_router_skips_root_callback(self) -> None: def test_callback_endpoint_sets_state(self) -> None: """Callback endpoint should store callback_path in request.state.""" svc = SWMLService( - name="ar_state", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="ar_state", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) captured_paths = [] @@ -1805,17 +2006,19 @@ def capture_callback(request: Any, body: dict[str, Any]) -> str | None: return None svc.register_routing_callback(capture_callback, "/sip") - # Override on_request to capture the callback_path - original_on_request = svc.on_request - def capturing_on_request(data: dict[str, Any] | None = None, cb_path: str | None = None) -> dict[str, Any] | None: + # Override on_request to capture the callback_path + def capturing_on_request( + data: dict[str, Any] | None = None, cb_path: str | None = None + ) -> dict[str, Any] | None: captured_paths.append(cb_path) return None svc.on_request = capturing_on_request # type: ignore[method-assign,assignment] # mock override client = _build_test_client(svc) resp = client.post( - "/sip", json={"key": "value"}, + "/sip", + json={"key": "value"}, headers=_auth_header("u", "p"), ) assert resp.status_code == 200 @@ -1828,7 +2031,10 @@ class TestRegisterRoutingCallbackNormalization: def test_path_without_leading_slash_normalized(self) -> None: """Path without leading slash should get one added.""" svc = SWMLService( - name="rc_norm", route="/", host="127.0.0.1", port=3001, + name="rc_norm", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) svc.register_routing_callback(lambda r, b: None, "sip") @@ -1837,7 +2043,10 @@ def test_path_without_leading_slash_normalized(self) -> None: def test_path_with_trailing_slash_stripped(self) -> None: """Trailing slash should be stripped from callback path.""" svc = SWMLService( - name="rc_trail", route="/", host="127.0.0.1", port=3001, + name="rc_trail", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) svc.register_routing_callback(lambda r, b: None, "/sip/") @@ -1846,7 +2055,10 @@ def test_path_with_trailing_slash_stripped(self) -> None: def test_path_both_normalizations(self) -> None: """Path with no leading slash and trailing slash should be fully normalized.""" svc = SWMLService( - name="rc_both", route="/", host="127.0.0.1", port=3001, + name="rc_both", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) svc.register_routing_callback(lambda r, b: None, "route/") @@ -1859,7 +2071,10 @@ class TestAddVerbToSectionWithHandler: def test_add_verb_to_section_with_valid_handler(self) -> None: """When a registered handler validates, verb should be added.""" svc = SWMLService( - name="vts_handler", route="/", host="127.0.0.1", port=3001, + name="vts_handler", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) mock_handler = Mock() @@ -1876,7 +2091,10 @@ def test_add_verb_to_section_with_valid_handler(self) -> None: def test_add_verb_to_section_with_invalid_handler_raises(self) -> None: """When a registered handler rejects, SchemaValidationError should be raised.""" svc = SWMLService( - name="vts_invalid", route="/", host="127.0.0.1", port=3001, + name="vts_invalid", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) mock_handler = Mock() @@ -1889,7 +2107,10 @@ def test_add_verb_to_section_with_invalid_handler_raises(self) -> None: def test_add_verb_to_section_schema_validation_error(self) -> None: """Schema-based validation failure should raise SchemaValidationError.""" svc = SWMLService( - name="vts_schema", route="/", host="127.0.0.1", port=3001, + name="vts_schema", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) # Mock schema_utils to return invalid @@ -1926,7 +2147,10 @@ class TestCreateVerbMethodsNoSchema: def test_create_verb_methods_no_schema_utils(self) -> None: """_create_verb_methods should return early when schema_utils is None.""" svc = SWMLService( - name="no_schema_verbs", route="/", host="127.0.0.1", port=3001, + name="no_schema_verbs", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) svc.schema_utils = None # type: ignore[assignment] # intentional: exercise missing-schema path @@ -1944,7 +2168,10 @@ class TestGetAttrCacheInit: def test_getattr_creates_cache_if_missing(self) -> None: """__getattr__ should create _verb_methods_cache if it doesn't exist.""" svc = SWMLService( - name="getattr_cache_init", route="/", host="127.0.0.1", port=3001, + name="getattr_cache_init", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) verb_names = svc.schema_utils.get_all_verb_names() @@ -1952,14 +2179,14 @@ def test_getattr_creates_cache_if_missing(self) -> None: pytest.skip("No verbs in schema") vn = verb_names[0] # Forcibly delete the cache attribute - if hasattr(svc, '_verb_methods_cache'): - del svc.__dict__['_verb_methods_cache'] + if hasattr(svc, "_verb_methods_cache"): + del svc.__dict__["_verb_methods_cache"] # Also remove the verb from __dict__ so __getattr__ triggers svc.__dict__.pop(vn, None) # Access the verb - should trigger __getattr__ which creates the cache method = getattr(svc, vn) assert callable(method) - assert hasattr(svc, '_verb_methods_cache') + assert hasattr(svc, "_verb_methods_cache") class TestGetBasicAuthEnvironmentSource: @@ -1967,12 +2194,18 @@ class TestGetBasicAuthEnvironmentSource: def test_env_source_detected(self) -> None: """When credentials match env vars, source should be 'environment'.""" - with patch.dict("os.environ", { - "SWML_BASIC_AUTH_USER": "envuser", - "SWML_BASIC_AUTH_PASSWORD": "envpass", - }): + with patch.dict( + "os.environ", + { + "SWML_BASIC_AUTH_USER": "envuser", + "SWML_BASIC_AUTH_PASSWORD": "envpass", + }, + ): svc = SWMLService( - name="auth_env_src", route="/", host="127.0.0.1", port=3001, + name="auth_env_src", + route="/", + host="127.0.0.1", + port=3001, basic_auth=("envuser", "envpass"), schema_validation=False, ) @@ -1984,12 +2217,19 @@ def test_env_source_detected(self) -> None: def test_auto_generated_source(self) -> None: """When credentials don't match env vars, source should be 'auto-generated'.""" svc = SWMLService( - name="auth_auto_src", route="/", host="127.0.0.1", port=3001, + name="auth_auto_src", + route="/", + host="127.0.0.1", + port=3001, basic_auth=("myuser", "mypass"), schema_validation=False, ) u, p, source = svc.get_basic_auth_credentials(include_source=True) # type: ignore[misc] # include_source=True returns 3-tuple assert source == "auto-generated" + # "auto-generated" classifies the SOURCE (they match no env var); the + # explicitly-passed credentials must still come back verbatim. + assert u == "myuser" + assert p == "mypass" class TestGetBaseUrlDomainHttp80: @@ -1998,7 +2238,10 @@ class TestGetBaseUrlDomainHttp80: def test_ssl_domain_http_port_80(self) -> None: """SSL with domain and port 80 should not include :80.""" svc = SWMLService( - name="url_domain_80", route="/", host="0.0.0.0", port=80, + name="url_domain_80", + route="/", + host="0.0.0.0", + port=80, schema_validation=False, ) svc._proxy_url_base = None @@ -2011,7 +2254,10 @@ def test_ssl_domain_http_port_80(self) -> None: def test_no_ssl_domain_port_80(self) -> None: """No SSL, with domain, port 80 should produce http://domain (no port).""" svc = SWMLService( - name="url_nossldom80", route="/", host="0.0.0.0", port=80, + name="url_nossldom80", + route="/", + host="0.0.0.0", + port=80, schema_validation=False, ) svc._proxy_url_base = None @@ -2023,7 +2269,10 @@ def test_no_ssl_domain_port_80(self) -> None: def test_ssl_domain_https_443(self) -> None: """SSL with domain and port 443 should not include :443.""" svc = SWMLService( - name="url_ssl443", route="/", host="0.0.0.0", port=443, + name="url_ssl443", + route="/", + host="0.0.0.0", + port=443, schema_validation=False, ) svc._proxy_url_base = None @@ -2037,7 +2286,11 @@ def test_ssl_domain_https_443(self) -> None: class TestProxyDetectionDebug: """Test proxy debug logging (line 1170).""" - def _make_request(self, headers: dict[str, str] | None = None, url: str = "http://127.0.0.1:3001/test") -> Mock: + def _make_request( + self, + headers: dict[str, str] | None = None, + url: str = "http://127.0.0.1:3001/test", + ) -> Mock: request = Mock() _headers = headers or {} request.headers = _headers @@ -2049,25 +2302,26 @@ def _make_request(self, headers: dict[str, str] | None = None, url: str = "http: def test_proxy_debug_mode_logs(self) -> None: """With _proxy_debug=True and no proxy detected, should not crash.""" svc = SWMLService( - name="proxy_debug", route="/test", host="127.0.0.1", port=3001, + name="proxy_debug", + route="/test", + host="127.0.0.1", + port=3001, schema_validation=False, ) svc._proxy_url_base = None svc._proxy_debug = True # Use a URL that starts with the local host to avoid transparent proxy detection request = self._make_request(headers={}, url="http://127.0.0.1:3001/test") - # The log.warning call for X-Forwarded-For has a known structlog - # 'message' parameter conflict, so catch TypeError if triggered. - try: - svc._detect_proxy_from_request(request) - except TypeError: - pass + svc._detect_proxy_from_request(request) assert svc._proxy_url_base is None def test_proxy_debug_mode_false(self) -> None: """With _proxy_debug=False, detection still works normally.""" svc = SWMLService( - name="proxy_nodebug", route="/test", host="127.0.0.1", port=3001, + name="proxy_nodebug", + route="/test", + host="127.0.0.1", + port=3001, schema_validation=False, ) svc._proxy_url_base = None @@ -2080,7 +2334,11 @@ def test_proxy_debug_mode_false(self) -> None: class TestForwardedHeaderParseError: """Test forwarded header parse error (lines 1131-1132).""" - def _make_request(self, headers: dict[str, str] | None = None, url: str = "http://127.0.0.1:3001/test") -> Mock: + def _make_request( + self, + headers: dict[str, str] | None = None, + url: str = "http://127.0.0.1:3001/test", + ) -> Mock: request = Mock() _headers = headers or {} request.headers = _headers @@ -2092,15 +2350,20 @@ def _make_request(self, headers: dict[str, str] | None = None, url: str = "http: def test_forwarded_header_causes_exception(self) -> None: """A Forwarded header that triggers a parse exception should be handled.""" svc = SWMLService( - name="fwd_err", route="/test", host="127.0.0.1", port=3001, + name="fwd_err", + route="/test", + host="127.0.0.1", + port=3001, schema_validation=False, ) svc._proxy_url_base = None # Create a header value where the host= part's split causes an issue # by having an extremely malformed value - request = self._make_request(headers={ - "Forwarded": "host=" + "x" * 0 + ";proto=", - }) + request = self._make_request( + headers={ + "Forwarded": "host=" + "x" * 0 + ";proto=", + } + ) # Should not raise svc._detect_proxy_from_request(request) @@ -2112,7 +2375,10 @@ def test_proxy_url_base_env_sets_attribute(self) -> None: """SWML_PROXY_URL_BASE env var should set _proxy_url_base.""" with patch.dict("os.environ", {"SWML_PROXY_URL_BASE": "https://my-proxy.com"}): svc = SWMLService( - name="proxy_env", route="/", host="127.0.0.1", port=3001, + name="proxy_env", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) assert svc._proxy_url_base == "https://my-proxy.com" @@ -2124,7 +2390,10 @@ def test_no_proxy_url_base_env(self) -> None: # Remove the env var if it exists os.environ.pop("SWML_PROXY_URL_BASE", None) svc = SWMLService( - name="no_proxy_env", route="/", host="127.0.0.1", port=3001, + name="no_proxy_env", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) assert svc._proxy_url_base is None @@ -2137,7 +2406,10 @@ class TestFindSchemaPath: def test_find_schema_path_returns_string(self) -> None: """_find_schema_path should return a string path when schema is found.""" svc = SWMLService( - name="schema_find", route="/", host="127.0.0.1", port=3001, + name="schema_find", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) result = svc._find_schema_path() @@ -2149,13 +2421,17 @@ def test_find_schema_path_returns_string(self) -> None: def test_find_schema_path_importlib_fails_fallback(self) -> None: """When importlib.resources fails, should fall back to file search.""" svc = SWMLService( - name="schema_fallback", route="/", host="127.0.0.1", port=3001, + name="schema_fallback", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) # Patch at the module level inside _find_schema_path # The method does `import importlib.resources` then calls `.files()` # We need to make files() raise and also make the fallback `path()` raise import importlib.resources as ir + original_files = ir.files try: ir.files = Mock(side_effect=ImportError("mocked")) @@ -2168,10 +2444,14 @@ def test_find_schema_path_importlib_fails_fallback(self) -> None: def test_find_schema_path_nothing_found(self) -> None: """When no schema file exists anywhere, should return None.""" svc = SWMLService( - name="schema_none", route="/", host="127.0.0.1", port=3001, + name="schema_none", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) import importlib.resources as ir + original_files = ir.files try: ir.files = Mock(side_effect=ImportError("mocked")) @@ -2184,10 +2464,14 @@ def test_find_schema_path_nothing_found(self) -> None: def test_find_schema_path_manual_search_finds_file(self) -> None: """When importlib fails but a file exists in manual paths, it should be found.""" svc = SWMLService( - name="schema_manual", route="/", host="127.0.0.1", port=3001, + name="schema_manual", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) import importlib.resources as ir + original_files = ir.files original_exists = os.path.exists try: @@ -2213,8 +2497,12 @@ class TestServeCatchAllRoute: def test_catch_all_exact_route_match(self, mock_uvicorn: MagicMock) -> None: """Catch-all route should handle exact route match.""" svc = SWMLService( - name="catch_exact", route="/agent", host="0.0.0.0", port=3000, - basic_auth=("u", "p"), schema_validation=False, + name="catch_exact", + route="/agent", + host="0.0.0.0", + port=3000, + basic_auth=("u", "p"), + schema_validation=False, ) mock_uvicorn.run = Mock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): @@ -2229,8 +2517,12 @@ def test_catch_all_exact_route_match(self, mock_uvicorn: MagicMock) -> None: def test_catch_all_route_with_trailing_slash(self, mock_uvicorn: MagicMock) -> None: """Catch-all route should handle route with trailing slash.""" svc = SWMLService( - name="catch_trail", route="/agent", host="0.0.0.0", port=3000, - basic_auth=("u", "p"), schema_validation=False, + name="catch_trail", + route="/agent", + host="0.0.0.0", + port=3000, + basic_auth=("u", "p"), + schema_validation=False, ) mock_uvicorn.run = Mock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): @@ -2244,8 +2536,12 @@ def test_catch_all_route_with_trailing_slash(self, mock_uvicorn: MagicMock) -> N def test_catch_all_no_match(self, mock_uvicorn: MagicMock) -> None: """Catch-all route should return error for unmatched paths.""" svc = SWMLService( - name="catch_nomatch", route="/agent", host="0.0.0.0", port=3000, - basic_auth=("u", "p"), schema_validation=False, + name="catch_nomatch", + route="/agent", + host="0.0.0.0", + port=3000, + basic_auth=("u", "p"), + schema_validation=False, ) mock_uvicorn.run = Mock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): @@ -2258,11 +2554,17 @@ def test_catch_all_no_match(self, mock_uvicorn: MagicMock) -> None: assert "error" in body @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_catch_all_with_routing_callback_subpath(self, mock_uvicorn: MagicMock) -> None: + def test_catch_all_with_routing_callback_subpath( + self, mock_uvicorn: MagicMock + ) -> None: """Catch-all route should forward to routing callback subpath.""" svc = SWMLService( - name="catch_cb", route="/agent", host="0.0.0.0", port=3000, - basic_auth=("u", "p"), schema_validation=False, + name="catch_cb", + route="/agent", + host="0.0.0.0", + port=3000, + basic_auth=("u", "p"), + schema_validation=False, ) svc.register_routing_callback(lambda r, b: None, "/sip") mock_uvicorn.run = Mock() @@ -2271,7 +2573,8 @@ def test_catch_all_with_routing_callback_subpath(self, mock_uvicorn: MagicMock) assert svc._app is not None client = TestClient(svc._app, raise_server_exceptions=False) resp = client.post( - "/agent/sip", json={"key": "value"}, + "/agent/sip", + json={"key": "value"}, headers=_auth_header("u", "p"), ) assert resp.status_code == 200 @@ -2280,8 +2583,12 @@ def test_catch_all_with_routing_callback_subpath(self, mock_uvicorn: MagicMock) def test_catch_all_root_route_match(self, mock_uvicorn: MagicMock) -> None: """When route is '/', catch-all should handle sub-paths.""" svc = SWMLService( - name="catch_root", route="/", host="0.0.0.0", port=3000, - basic_auth=("u", "p"), schema_validation=False, + name="catch_root", + route="/", + host="0.0.0.0", + port=3000, + basic_auth=("u", "p"), + schema_validation=False, ) mock_uvicorn.run = Mock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): @@ -2297,18 +2604,30 @@ class TestServeDomainOverride: """Test serve() domain override (line 766).""" @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_overrides_domain(self, mock_uvicorn: MagicMock) -> None: + def test_serve_overrides_domain( + self, mock_uvicorn: MagicMock, tmp_path: Path + ) -> None: """serve(domain=...) should override the service domain.""" + cert = tmp_path / "cert.pem" + key = tmp_path / "key.pem" + cert.write_text("cert") + key.write_text("key") svc = SWMLService( - name="srv_domain", route="/", host="0.0.0.0", port=443, + name="srv_domain", + route="/", + host="0.0.0.0", + port=443, schema_validation=False, ) assert svc.domain is None or svc.domain != "new.example.com" - svc.security.validate_ssl_config = Mock(return_value=(True, None)) # type: ignore[method-assign] # mock mock_uvicorn.run = Mock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): - svc.serve(ssl_enabled=True, domain="new.example.com", - ssl_cert="/cert.pem", ssl_key="/key.pem") + svc.serve( + ssl_enabled=True, + domain="new.example.com", + ssl_cert=str(cert), + ssl_key=str(key), + ) assert svc.domain == "new.example.com" @@ -2318,7 +2637,10 @@ class TestVerbMethodDocstrings: def test_verb_with_no_description_in_schema(self) -> None: """Verb with no 'description' in properties should still have a docstring.""" svc = SWMLService( - name="doc_test", route="/", host="127.0.0.1", port=3001, + name="doc_test", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) verb_names = svc.schema_utils.get_all_verb_names() @@ -2340,7 +2662,10 @@ def test_verb_with_no_description_in_schema(self) -> None: def test_verb_with_description_in_schema(self) -> None: """Verb with 'description' in properties should include it in docstring.""" svc = SWMLService( - name="doc_desc_test", route="/", host="127.0.0.1", port=3001, + name="doc_desc_test", + route="/", + host="127.0.0.1", + port=3001, schema_validation=False, ) verb_names = svc.schema_utils.get_all_verb_names() @@ -2362,7 +2687,10 @@ class TestSchemaNotFoundWarning: def test_schema_not_found_still_initializes(self) -> None: """Service should still initialize when schema is not found.""" svc = SWMLService( - name="no_schema_warn", route="/", host="127.0.0.1", port=3001, + name="no_schema_warn", + route="/", + host="127.0.0.1", + port=3001, schema_path="/nonexistent/path/schema.json", schema_validation=False, ) @@ -2377,8 +2705,12 @@ class TestDecomposedHandleRequestCore: def _svc(self) -> SWMLService: return SWMLService( - name="hr_core", route="/", host="127.0.0.1", port=3001, - basic_auth=("u", "p"), schema_validation=False, + name="hr_core", + route="/", + host="127.0.0.1", + port=3001, + basic_auth=("u", "p"), + schema_validation=False, ) # -- 200: plain SWML render -- @@ -2472,9 +2804,7 @@ def cb(body: dict[str, Any], headers: dict[str, Any]) -> str | None: def test_core_307_matches_fastapi_path(self) -> None: """The FastAPI path produces the same 307 + Location for the same callback.""" svc = self._svc() - svc.register_routing_callback( - lambda body, headers: "/other-agent", "/sip" - ) + svc.register_routing_callback(lambda body, headers: "/other-agent", "/sip") core_status, core_headers, _ = svc.handle_request( "POST", "http://127.0.0.1:3001/sip", @@ -2505,4 +2835,4 @@ def test_core_routing_none_continues_to_200(self) -> None: ) assert status == 200 assert json.loads(body_str)["version"] == "1.0.0" - assert svc.schema_utils is not None \ No newline at end of file + assert svc.schema_utils is not None diff --git a/tests/unit/core/test_swml_service_swaig.py b/tests/unit/core/test_swml_service_swaig.py index ba0ea9ee..f1ae9557 100644 --- a/tests/unit/core/test_swml_service_swaig.py +++ b/tests/unit/core/test_swml_service_swaig.py @@ -15,7 +15,6 @@ """ import asyncio -import base64 import json from collections.abc import Coroutine from typing import Any, TypeVar @@ -73,6 +72,7 @@ def _service(**kwargs: Any) -> SWMLService: # SWMLService-only SWAIG hosting # --------------------------------------------------------------------------- + class TestSWMLServiceHasSWAIGCapability: """The lift gives plain SWMLService instances SWAIG-hosting capability.""" @@ -227,6 +227,7 @@ def lookup(args: dict[str, Any], raw_data: Any) -> FunctionResult: # Sidecar usage pattern # --------------------------------------------------------------------------- + class TestSidecarPatternViaSWMLService: """Sidecar-flavored SWML services need three things from SWMLService: 1. arbitrary verb emission (already supported via add_verb / add_section), @@ -245,17 +246,23 @@ def test_can_emit_ai_sidecar_verb(self) -> None: assert ok # ai_sidecar isn't in the live SWML schema yet — bypass via raw doc. # Once the schema lands, callers will use add_verb_to_section directly. - svc._current_document["sections"]["main"].append({"ai_sidecar": { - "prompt": "real-time copilot", - "lang": "en-US", - "direction": ["remote-caller", "local-caller"], - }}) + svc._current_document["sections"]["main"].append( + { + "ai_sidecar": { + "prompt": "real-time copilot", + "lang": "en-US", + "direction": ["remote-caller", "local-caller"], + } + } + ) rendered = json.loads(svc.render_document()) - verbs = [list(v.keys())[0] for v in rendered["sections"]["main"]] + verbs = [next(iter(v.keys())) for v in rendered["sections"]["main"]] assert "answer" in verbs assert "ai_sidecar" in verbs - def test_full_sidecar_pattern_emit_swml_register_tool_register_event_sink(self) -> None: + def test_full_sidecar_pattern_emit_swml_register_tool_register_event_sink( + self, + ) -> None: svc = _service() # 1. Build the SWML doc. @@ -282,7 +289,7 @@ def lookup_competitor(args: dict[str, Any], raw_data: Any) -> FunctionResult: def on_event(request: Any, body: dict[str, Any]) -> None: events_seen.append(body.get("type")) - return None + return svc.register_routing_callback(on_event, path="/events") diff --git a/tests/unit/core/test_swml_strict_render.py b/tests/unit/core/test_swml_strict_render.py index de6085dc..5c0fb773 100644 --- a/tests/unit/core/test_swml_strict_render.py +++ b/tests/unit/core/test_swml_strict_render.py @@ -81,7 +81,9 @@ class TestMisspelledKeyRejected: ("prompt", {"txt": "hi"}), # misspelled text ], ) - def test_misspelled_or_unknown_key_raises(self, verb: str, config: dict[str, Any]) -> None: + def test_misspelled_or_unknown_key_raises( + self, verb: str, config: dict[str, Any] + ) -> None: svc = _strict_service() with pytest.raises(SchemaValidationError): svc.add_verb(verb, config) diff --git a/tests/unit/livewire/test_livewire.py b/tests/unit/livewire/test_livewire.py index 449b1f6b..49e3c6cb 100644 --- a/tests/unit/livewire/test_livewire.py +++ b/tests/unit/livewire/test_livewire.py @@ -56,6 +56,7 @@ # Helpers # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def _reset_noop_trackers() -> Iterator[None]: """Reset all noop trackers between tests so 'log once' does not leak.""" @@ -70,6 +71,7 @@ def _reset_noop_trackers() -> Iterator[None]: # Agent creation # --------------------------------------------------------------------------- + class TestAgentCreation: """Test Agent class construction and properties.""" @@ -91,7 +93,7 @@ def greet(name: str) -> str: def test_creation_with_noop_params(self) -> None: """STT, TTS, VAD, turn_detection trigger noop logs.""" - agent = Agent( + Agent( instructions="test", stt="deepgram", tts="cartesia", @@ -189,6 +191,7 @@ async def test_tts_node_noop(self) -> None: # function_tool decorator # --------------------------------------------------------------------------- + class TestFunctionTool: """Test the @function_tool decorator.""" @@ -260,6 +263,7 @@ def typed(a: str, b: int, c: float, d: bool) -> str: # AgentSession # --------------------------------------------------------------------------- + class TestAgentSession: """Test AgentSession construction and methods.""" @@ -338,6 +342,7 @@ def test_noop_max_tool_steps(self) -> None: # RunContext # --------------------------------------------------------------------------- + class TestRunContext: """Test RunContext mirrors livekit RunContext.""" @@ -361,6 +366,7 @@ def test_creation_with_extras(self) -> None: # JobContext # --------------------------------------------------------------------------- + class TestJobContext: """Test JobContext noop methods.""" @@ -390,6 +396,7 @@ def test_proc(self) -> None: # Room / JobProcess # --------------------------------------------------------------------------- + class TestRoom: def test_name(self) -> None: assert Room.name == "livewire-room" @@ -406,6 +413,7 @@ def test_userdata(self) -> None: # Plugin stubs # --------------------------------------------------------------------------- + class TestPluginStubs: """Test that plugin stubs construct without error.""" @@ -438,19 +446,23 @@ def test_silero_vad_load(self) -> None: # Inference stubs # --------------------------------------------------------------------------- + class TestInferenceStubs: def test_inference_stt(self) -> None: from signalwire.livewire import InferenceSTT + stt = InferenceSTT("whisper-large-v3") assert stt.model == "whisper-large-v3" def test_inference_llm(self) -> None: from signalwire.livewire import InferenceLLM + llm = InferenceLLM("gpt-4o") assert llm.model == "gpt-4o" def test_inference_tts(self) -> None: from signalwire.livewire import InferenceTTS + tts = InferenceTTS("tts-1") assert tts.model == "tts-1" @@ -459,6 +471,7 @@ def test_inference_tts(self) -> None: # Noop logging (log once per feature) # --------------------------------------------------------------------------- + class TestNoopLogging: """Verify that noop messages are logged at most once.""" @@ -491,6 +504,7 @@ def test_agent_stt_logs_once(self) -> None: # Banner and tips # --------------------------------------------------------------------------- + class TestBannerAndTips: """Test banner printing and tip selection.""" @@ -510,18 +524,22 @@ def test_tips_are_strings(self) -> None: def test_print_banner_tty(self) -> None: buf = io.StringIO() - with patch.object(sys, "stderr", buf): - with patch.object(buf, "isatty", return_value=True): - _print_banner() + with ( + patch.object(sys, "stderr", buf), + patch.object(buf, "isatty", return_value=True), + ): + _print_banner() output = buf.getvalue() assert "\033[36m" in output # cyan assert "LiveKit-compatible" in output def test_print_banner_no_tty(self) -> None: buf = io.StringIO() - with patch.object(sys, "stderr", buf): - with patch.object(buf, "isatty", return_value=False): - _print_banner() + with ( + patch.object(sys, "stderr", buf), + patch.object(buf, "isatty", return_value=False), + ): + _print_banner() output = buf.getvalue() assert "\033[36m" not in output assert "LiveKit-compatible" in output @@ -538,6 +556,7 @@ def test_print_tip(self) -> None: # Exceptions / signals # --------------------------------------------------------------------------- + class TestExceptionsAndSignals: def test_stop_response_is_exception(self) -> None: assert issubclass(StopResponse, Exception) @@ -556,6 +575,7 @@ def test_agent_handoff(self) -> None: # ChatContext # --------------------------------------------------------------------------- + class TestChatContext: def test_basic(self) -> None: ctx = ChatContext() @@ -573,6 +593,7 @@ def test_append(self) -> None: # Namespace aliases # --------------------------------------------------------------------------- + class TestNamespaces: def test_voice_namespace(self) -> None: assert voice.Agent is Agent @@ -588,6 +609,7 @@ def test_cli_namespace(self) -> None: def test_inference_namespace(self) -> None: from signalwire.livewire import InferenceSTT, InferenceLLM, InferenceTTS + assert inference.STT is InferenceSTT assert inference.LLM is InferenceLLM assert inference.TTS is InferenceTTS @@ -597,6 +619,7 @@ def test_inference_namespace(self) -> None: # AgentServer # --------------------------------------------------------------------------- + class TestAgentServer: def test_basic_creation(self) -> None: server = AgentServer() @@ -628,18 +651,20 @@ async def entrypoint(ctx: JobContext) -> None: # NOT_GIVEN sentinel # --------------------------------------------------------------------------- + class TestNotGiven: def test_sentinel_identity(self) -> None: assert NOT_GIVEN is NOT_GIVEN assert NOT_GIVEN is not None assert NOT_GIVEN is not False - assert NOT_GIVEN is not 0 + assert NOT_GIVEN != 0 # --------------------------------------------------------------------------- # Integration: AgentSession._build_sw_agent # --------------------------------------------------------------------------- + class TestBuildSwAgent: """Test that _build_sw_agent creates a valid SignalWire AgentBase.""" @@ -670,8 +695,11 @@ def ping(msg: str) -> str: sw = session._build_sw_agent() # The tool should be registered - tool_names = [f.name for f in sw._tool_registry._swaig_functions.values() - if hasattr(f, "name")] + tool_names = [ + f.name + for f in sw._tool_registry._swaig_functions.values() + if hasattr(f, "name") + ] assert "ping" in tool_names @pytest.mark.asyncio diff --git a/tests/unit/mcp_gateway/test_gateway_service.py b/tests/unit/mcp_gateway/test_gateway_service.py index 1f8679b4..a99a17b4 100644 --- a/tests/unit/mcp_gateway/test_gateway_service.py +++ b/tests/unit/mcp_gateway/test_gateway_service.py @@ -14,25 +14,22 @@ import json import os -import sys import base64 import logging -import threading -import re from pathlib import Path from typing import Any, TYPE_CHECKING import pytest -from unittest.mock import Mock, patch, MagicMock, call -from datetime import datetime +from unittest.mock import patch, MagicMock if TYPE_CHECKING: from signalwire.mcp_gateway.gateway_service import MCPGateway - from werkzeug.test import TestResponse # Skip the entire module when Flask is not installed flask = pytest.importorskip("flask", reason="flask is required for MCP Gateway tests") -pytest.importorskip("flask_limiter", reason="flask_limiter is required for MCP Gateway tests") +pytest.importorskip( + "flask_limiter", reason="flask_limiter is required for MCP Gateway tests" +) # --------------------------------------------------------------------------- @@ -41,15 +38,25 @@ # filesystem, network, or real MCP processes. # --------------------------------------------------------------------------- +# Fixture credentials. The gateway config below and the _auth_headers_* helpers +# MUST agree, or every authenticated request in this file 401s — so they share +# these constants instead of repeating the literals. Naming them also keeps the +# values out of function signatures, where they read as hardcoded credential +# defaults. +_FIXTURE_USER = "admin" +_FIXTURE_PASSWORD = "secret" +_FIXTURE_BEARER_TOKEN = "test-bearer-token" + + def _minimal_config() -> dict[str, Any]: """Return a minimal valid configuration dictionary.""" return { "server": { "host": "0.0.0.0", "port": 8080, - "auth_user": "admin", - "auth_password": "secret", - "auth_token": "test-bearer-token", + "auth_user": _FIXTURE_USER, + "auth_password": _FIXTURE_PASSWORD, + "auth_token": _FIXTURE_BEARER_TOKEN, }, "services": {}, "session": { @@ -70,7 +77,9 @@ def _minimal_config() -> dict[str, Any]: } -def _create_gateway(config: dict[str, Any] | None = None) -> tuple["MCPGateway", dict[str, MagicMock]]: +def _create_gateway( + config: dict[str, Any] | None = None, +) -> tuple["MCPGateway", dict[str, MagicMock]]: """ Instantiate an ``MCPGateway`` with every external dependency mocked. @@ -136,13 +145,15 @@ def _create_gateway(config: dict[str, Any] | None = None) -> tuple["MCPGateway", return gateway, mocks -def _auth_headers_basic(user: str = "admin", password: str = "secret") -> dict[str, str]: +def _auth_headers_basic( + user: str = _FIXTURE_USER, password: str = _FIXTURE_PASSWORD +) -> dict[str, str]: """Return HTTP headers for Basic authentication.""" creds = base64.b64encode(f"{user}:{password}".encode()).decode() return {"Authorization": f"Basic {creds}"} -def _auth_headers_bearer(token: str = "test-bearer-token") -> dict[str, str]: +def _auth_headers_bearer(token: str) -> dict[str, str]: """Return HTTP headers for Bearer token authentication.""" return {"Authorization": f"Bearer {token}"} @@ -151,12 +162,13 @@ def _auth_headers_bearer(token: str = "test-bearer-token") -> dict[str, str]: # Tests: Initialization # =================================================================== + class TestMCPGatewayInit: """Tests for MCPGateway construction and configuration.""" def test_init_loads_config_via_config_loader(self) -> None: """When ConfigLoader has_config() returns True, config is loaded through it.""" - gateway, mocks = _create_gateway() + _gateway, mocks = _create_gateway() assert mocks["config_loader"].has_config.called assert mocks["config_loader"].get_config.called assert mocks["config_loader"].substitute_vars.called @@ -170,12 +182,18 @@ def test_init_falls_back_to_load_config_when_no_config_loader(self) -> None: mock_config_loader = MagicMock() mock_config_loader.has_config.return_value = False - with patch("signalwire.mcp_gateway.gateway_service.ConfigLoader", return_value=mock_config_loader), \ - patch("signalwire.mcp_gateway.gateway_service.SecurityConfig"), \ - patch("signalwire.mcp_gateway.gateway_service.MCPManager") as mock_mcp_cls, \ - patch("signalwire.mcp_gateway.gateway_service.SessionManager") as mock_session_cls, \ - patch.object(MCPGateway, "_load_config", return_value=config) as mock_load: - + with ( + patch( + "signalwire.mcp_gateway.gateway_service.ConfigLoader", + return_value=mock_config_loader, + ), + patch("signalwire.mcp_gateway.gateway_service.SecurityConfig"), + patch("signalwire.mcp_gateway.gateway_service.MCPManager") as mock_mcp_cls, + patch( + "signalwire.mcp_gateway.gateway_service.SessionManager" + ) as mock_session_cls, + patch.object(MCPGateway, "_load_config", return_value=config) as mock_load, + ): mock_mcp_cls.return_value.validate_services.return_value = {} mock_session_cls.return_value.default_timeout = 300 @@ -200,7 +218,7 @@ def test_init_rate_limiter_configured(self) -> None: def test_init_validates_services_on_startup(self) -> None: """validate_services() is called during __init__.""" - gateway, mocks = _create_gateway() + _gateway, mocks = _create_gateway() mocks["mcp_manager"].validate_services.assert_called_once() def test_init_logs_warning_for_failed_validation(self) -> None: @@ -213,20 +231,28 @@ def test_init_logs_warning_for_failed_validation(self) -> None: mock_config_loader.get_config.return_value = config mock_config_loader.substitute_vars.return_value = config - with patch("signalwire.mcp_gateway.gateway_service.ConfigLoader", return_value=mock_config_loader), \ - patch("signalwire.mcp_gateway.gateway_service.SecurityConfig"), \ - patch("signalwire.mcp_gateway.gateway_service.MCPManager") as mcp_cls, \ - patch("signalwire.mcp_gateway.gateway_service.SessionManager") as sm_cls, \ - patch("signalwire.mcp_gateway.gateway_service.logger") as mock_logger: - - mcp_cls.return_value.validate_services.return_value = {"bad_svc": False, "good_svc": True} + with ( + patch( + "signalwire.mcp_gateway.gateway_service.ConfigLoader", + return_value=mock_config_loader, + ), + patch("signalwire.mcp_gateway.gateway_service.SecurityConfig"), + patch("signalwire.mcp_gateway.gateway_service.MCPManager") as mcp_cls, + patch("signalwire.mcp_gateway.gateway_service.SessionManager") as sm_cls, + patch("signalwire.mcp_gateway.gateway_service.logger") as mock_logger, + ): + mcp_cls.return_value.validate_services.return_value = { + "bad_svc": False, + "good_svc": True, + } sm_cls.return_value.default_timeout = 300 MCPGateway("fake.json") # At least one warning about 'bad_svc' failing validation - warning_calls = [c for c in mock_logger.warning.call_args_list - if "bad_svc" in str(c)] + warning_calls = [ + c for c in mock_logger.warning.call_args_list if "bad_svc" in str(c) + ] assert len(warning_calls) >= 1 def test_init_shutdown_flags_default(self) -> None: @@ -253,22 +279,29 @@ def test_init_security_config_created(self) -> None: mock_cl.get_config.return_value = config mock_cl.substitute_vars.return_value = config - with patch("signalwire.mcp_gateway.gateway_service.ConfigLoader", return_value=mock_cl), \ - patch("signalwire.mcp_gateway.gateway_service.SecurityConfig") as sec_cls, \ - patch("signalwire.mcp_gateway.gateway_service.MCPManager") as mcp_cls, \ - patch("signalwire.mcp_gateway.gateway_service.SessionManager") as sm_cls: - + with ( + patch( + "signalwire.mcp_gateway.gateway_service.ConfigLoader", + return_value=mock_cl, + ), + patch("signalwire.mcp_gateway.gateway_service.SecurityConfig") as sec_cls, + patch("signalwire.mcp_gateway.gateway_service.MCPManager") as mcp_cls, + patch("signalwire.mcp_gateway.gateway_service.SessionManager") as sm_cls, + ): mcp_cls.return_value.validate_services.return_value = {} sm_cls.return_value.default_timeout = 300 MCPGateway("myconfig.json") - sec_cls.assert_called_once_with(config_file="myconfig.json", service_name="mcp") + sec_cls.assert_called_once_with( + config_file="myconfig.json", service_name="mcp" + ) # =================================================================== # Tests: Input Validation Helpers # =================================================================== + class TestValidationHelpers: """Tests for _validate_service_name, _validate_session_id, _validate_tool_name.""" @@ -313,7 +346,9 @@ def test_validate_service_name_path_traversal(self) -> None: # -- session id --------------------------------------------------------- def test_validate_session_id_valid(self) -> None: - assert self.gateway._validate_session_id("sess-123.abc_def") == "sess-123.abc_def" + assert ( + self.gateway._validate_session_id("sess-123.abc_def") == "sess-123.abc_def" + ) def test_validate_session_id_empty(self) -> None: with pytest.raises(ValueError, match="Invalid session ID length"): @@ -361,6 +396,7 @@ def test_validate_tool_name_invalid_chars(self) -> None: # Tests: Security Event Logging # =================================================================== + class TestLogSecurityEvent: """Tests for _log_security_event.""" @@ -414,6 +450,7 @@ def test_log_security_event_preserves_non_string_values(self) -> None: # Tests: Environment Variable Substitution # =================================================================== + class TestSubstituteEnvVars: """Tests for _substitute_env_vars.""" @@ -440,7 +477,10 @@ def test_substitute_env_var_missing_with_default(self) -> None: env = os.environ.copy() env.pop("MISSING_VAR_XYZ", None) with patch.dict(os.environ, env, clear=True): - assert self.gateway._substitute_env_vars("${MISSING_VAR_XYZ|fallback}") == "fallback" + assert ( + self.gateway._substitute_env_vars("${MISSING_VAR_XYZ|fallback}") + == "fallback" + ) def test_substitute_env_var_present_with_default_ignored(self) -> None: with patch.dict(os.environ, {"MY_VAR": "real"}): @@ -472,6 +512,7 @@ def test_substitute_non_string_passthrough(self) -> None: # Tests: _load_config # =================================================================== + class TestLoadConfig: """Tests for the fallback _load_config method.""" @@ -487,7 +528,9 @@ def test_load_config_reads_existing_file(self, tmp_path: Path) -> None: loaded = self.gateway._load_config(str(config_file)) assert loaded["server"]["port"] == 8080 - def test_load_config_creates_default_when_nothing_exists(self, tmp_path: Path) -> None: + def test_load_config_creates_default_when_nothing_exists( + self, tmp_path: Path + ) -> None: config_path = str(tmp_path / "nonexistent.json") # Neither config_path nor sample_config.json exist with patch("os.path.exists", return_value=False): @@ -501,29 +544,33 @@ def test_load_config_creates_default_when_nothing_exists(self, tmp_path: Path) - def test_load_config_copies_sample_when_available(self, tmp_path: Path) -> None: config_path = str(tmp_path / "config.json") - call_count = [0] - def exists_side_effect(path: str) -> bool: - if path == config_path: - # First call: config doesn't exist; after copy it does - call_count[0] += 1 - return call_count[0] > 1 - if path == "sample_config.json": + def exists_side_effect(self: Path) -> bool: + # config.json is absent (so the sample path is taken); the sample is + # present. Delegate every other Path to the real filesystem. + if str(self) == config_path: + return False + if str(self) == "sample_config.json": return True - return False - - with patch("os.path.exists", side_effect=exists_side_effect), \ - patch("shutil.copy") as mock_copy: - mock_file_content = json.dumps(_minimal_config()) - mock_file = MagicMock() - mock_file.__enter__ = MagicMock(return_value=MagicMock( - read=MagicMock(return_value=mock_file_content) - )) - mock_file.__exit__ = MagicMock(return_value=False) - - with patch("builtins.open", return_value=mock_file): - self.gateway._load_config(config_path) - - mock_copy.assert_called_once_with("sample_config.json", config_path) + return os.path.exists(str(self)) # noqa: PTH110 # real-fs fallback inside a Path.exists patch; Path.exists is the patched target + + def copy_side_effect(src: str, dst: str) -> None: + # Stand in for the real copy so the source's own Path.open("r") has + # something real to read. + Path(dst).write_text(json.dumps(_minimal_config())) + + with ( + patch( + "signalwire.mcp_gateway.gateway_service.Path.exists", + autospec=True, + side_effect=exists_side_effect, + ), + patch("shutil.copy", side_effect=copy_side_effect) as mock_copy, + ): + loaded = self.gateway._load_config(config_path) + + mock_copy.assert_called_once_with("sample_config.json", config_path) + # The config actually produced by the copied sample was loaded. + assert loaded["server"]["port"] == _minimal_config()["server"]["port"] def test_load_config_converts_string_port_to_int(self, tmp_path: Path) -> None: config_data = _minimal_config() @@ -570,6 +617,7 @@ def test_load_config_handles_invalid_session_values(self, tmp_path: Path) -> Non # Tests: Authentication # =================================================================== + class TestAuthentication: """Tests for _check_auth decorator and auth routes.""" @@ -581,7 +629,7 @@ def _setup(self) -> None: def test_bearer_token_auth_success(self) -> None: resp = self.client.get( "/services", - headers=_auth_headers_bearer("test-bearer-token"), + headers=_auth_headers_bearer(_FIXTURE_BEARER_TOKEN), ) assert resp.status_code == 200 @@ -651,6 +699,7 @@ def test_auth_without_token_config_falls_through_to_basic(self) -> None: # Tests: Health Endpoint # =================================================================== + class TestHealthEndpoint: """Tests for GET /health (no auth required).""" @@ -680,6 +729,7 @@ def test_health_no_auth_required(self) -> None: # Tests: Security Headers # =================================================================== + class TestSecurityHeaders: """Verify that security headers are set on every response.""" @@ -709,6 +759,7 @@ def test_content_security_policy(self) -> None: # Tests: List Services # =================================================================== + class TestListServicesEndpoint: """Tests for GET /services.""" @@ -737,6 +788,7 @@ def test_list_services_empty(self) -> None: # Tests: Get Service Tools # =================================================================== + class TestGetServiceToolsEndpoint: """Tests for GET /services//tools.""" @@ -780,6 +832,7 @@ def test_get_tools_service_error(self) -> None: # Tests: Call Service Tool # =================================================================== + class TestCallServiceToolEndpoint: """Tests for POST /services//call.""" @@ -793,7 +846,10 @@ def _post_call( service_name: str = "todo", payload: dict[str, Any] | None = None, headers: dict[str, str] | None = None, - ) -> "TestResponse": + ) -> Any: + # Any, not "TestResponse": MCPGateway.app is pinned to Any so this file + # type-checks identically with and without the optional mcp-gateway + # extra installed, which makes the test client (and its responses) Any. if payload is None: payload = { "tool": "add_todo", @@ -855,18 +911,22 @@ def test_call_tool_service_mismatch(self) -> None: assert "other_service" in resp.get_json()["error"] def test_call_tool_missing_tool_parameter(self) -> None: - resp = self._post_call(payload={ - "session_id": "sess-1", - "arguments": {}, - }) + resp = self._post_call( + payload={ + "session_id": "sess-1", + "arguments": {}, + } + ) assert resp.status_code == 400 assert "tool" in resp.get_json()["error"].lower() def test_call_tool_missing_session_id(self) -> None: - resp = self._post_call(payload={ - "tool": "add_todo", - "arguments": {}, - }) + resp = self._post_call( + payload={ + "tool": "add_todo", + "arguments": {}, + } + ) assert resp.status_code == 400 assert "session_id" in resp.get_json()["error"].lower() @@ -883,50 +943,60 @@ def test_call_tool_invalid_json_body(self) -> None: assert resp.status_code == 500 def test_call_tool_invalid_arguments_type(self) -> None: - resp = self._post_call(payload={ - "tool": "add_todo", - "session_id": "sess-1", - "arguments": "not_a_dict", - }) + resp = self._post_call( + payload={ + "tool": "add_todo", + "session_id": "sess-1", + "arguments": "not_a_dict", + } + ) assert resp.status_code == 400 assert "arguments" in resp.get_json()["error"].lower() def test_call_tool_invalid_timeout_negative(self) -> None: - resp = self._post_call(payload={ - "tool": "add_todo", - "session_id": "sess-1", - "arguments": {}, - "timeout": -5, - }) + resp = self._post_call( + payload={ + "tool": "add_todo", + "session_id": "sess-1", + "arguments": {}, + "timeout": -5, + } + ) assert resp.status_code == 400 assert "timeout" in resp.get_json()["error"].lower() def test_call_tool_invalid_timeout_too_large(self) -> None: - resp = self._post_call(payload={ - "tool": "add_todo", - "session_id": "sess-1", - "arguments": {}, - "timeout": 9999, - }) + resp = self._post_call( + payload={ + "tool": "add_todo", + "session_id": "sess-1", + "arguments": {}, + "timeout": 9999, + } + ) assert resp.status_code == 400 assert "timeout" in resp.get_json()["error"].lower() def test_call_tool_invalid_timeout_string(self) -> None: - resp = self._post_call(payload={ - "tool": "add_todo", - "session_id": "sess-1", - "arguments": {}, - "timeout": "fast", - }) + resp = self._post_call( + payload={ + "tool": "add_todo", + "session_id": "sess-1", + "arguments": {}, + "timeout": "fast", + } + ) assert resp.status_code == 400 def test_call_tool_invalid_metadata_type(self) -> None: - resp = self._post_call(payload={ - "tool": "add_todo", - "session_id": "sess-1", - "arguments": {}, - "metadata": "not_a_dict", - }) + resp = self._post_call( + payload={ + "tool": "add_todo", + "session_id": "sess-1", + "arguments": {}, + "metadata": "not_a_dict", + } + ) assert resp.status_code == 400 assert "metadata" in resp.get_json()["error"].lower() @@ -985,7 +1055,9 @@ def test_call_tool_empty_content_list(self) -> None: def test_call_tool_session_creation_failure(self) -> None: """Error when session creation fails.""" self.mocks["session_manager"].get_session.return_value = None - self.mocks["mcp_manager"].create_client.side_effect = RuntimeError("cannot start") + self.mocks["mcp_manager"].create_client.side_effect = RuntimeError( + "cannot start" + ) resp = self._post_call() assert resp.status_code == 500 @@ -1032,6 +1104,7 @@ def test_call_tool_uses_default_timeout(self) -> None: # Tests: List Sessions # =================================================================== + class TestListSessionsEndpoint: """Tests for GET /sessions.""" @@ -1064,6 +1137,7 @@ def test_list_sessions_requires_auth(self) -> None: # Tests: Close Session # =================================================================== + class TestCloseSessionEndpoint: """Tests for DELETE /sessions/.""" @@ -1107,7 +1181,9 @@ def test_close_session_logs_security_event(self) -> None: "/sessions/sess-123", headers=_auth_headers_basic(), ) - closed_calls = [c for c in mock_log.call_args_list if c[0][0] == "session_closed"] + closed_calls = [ + c for c in mock_log.call_args_list if c[0][0] == "session_closed" + ] assert len(closed_calls) >= 1 @@ -1115,6 +1191,7 @@ def test_close_session_logs_security_event(self) -> None: # Tests: Signal Handler # =================================================================== + class TestSignalHandler: """Tests for _signal_handler.""" @@ -1134,6 +1211,7 @@ def test_signal_handler_calls_server_shutdown_when_server_exists(self) -> None: # Server.shutdown is called in a daemon thread; give it a moment import time + time.sleep(0.2) mock_server.shutdown.assert_called() @@ -1147,6 +1225,7 @@ def test_signal_handler_tolerates_no_server(self) -> None: # Tests: Shutdown # =================================================================== + class TestShutdown: """Tests for shutdown().""" @@ -1200,6 +1279,7 @@ def test_shutdown_tolerates_no_server(self) -> None: # Tests: Run Method # =================================================================== + class TestRunMethod: """Tests for run().""" @@ -1209,10 +1289,14 @@ def _setup(self) -> None: def test_run_creates_server_and_serves(self) -> None: mock_server = MagicMock() - with patch("signalwire.mcp_gateway.gateway_service.make_server", return_value=mock_server) as mock_make, \ - patch("signalwire.mcp_gateway.gateway_service.signal") as mock_signal, \ - patch("os.path.exists", return_value=False): - + with ( + patch( + "signalwire.mcp_gateway.gateway_service.make_server", + return_value=mock_server, + ) as mock_make, + patch("signalwire.mcp_gateway.gateway_service.signal"), + patch("os.path.exists", return_value=False), + ): # simulate immediate shutdown via KeyboardInterrupt mock_server.serve_forever.side_effect = KeyboardInterrupt() self.gateway.run() @@ -1226,11 +1310,19 @@ def test_run_enables_ssl_when_cert_exists(self) -> None: mock_server = MagicMock() mock_server.serve_forever.side_effect = KeyboardInterrupt() - with patch("signalwire.mcp_gateway.gateway_service.make_server", return_value=mock_server), \ - patch("signalwire.mcp_gateway.gateway_service.signal"), \ - patch("os.path.exists", return_value=True), \ - patch("signalwire.mcp_gateway.gateway_service.ssl") as mock_ssl: - + with ( + patch( + "signalwire.mcp_gateway.gateway_service.make_server", + return_value=mock_server, + ), + patch("signalwire.mcp_gateway.gateway_service.signal"), + patch( + "signalwire.mcp_gateway.gateway_service.Path.exists", + autospec=True, + side_effect=lambda self: str(self) == "certs/server.pem", + ), + patch("signalwire.mcp_gateway.gateway_service.ssl") as mock_ssl, + ): mock_ctx = MagicMock() mock_ssl.SSLContext.return_value = mock_ctx self.gateway.run() @@ -1242,11 +1334,15 @@ def test_run_calls_shutdown_on_exit(self) -> None: mock_server = MagicMock() mock_server.serve_forever.side_effect = KeyboardInterrupt() - with patch("signalwire.mcp_gateway.gateway_service.make_server", return_value=mock_server), \ - patch("signalwire.mcp_gateway.gateway_service.signal"), \ - patch("os.path.exists", return_value=False), \ - patch.object(self.gateway, "shutdown") as mock_shutdown: - + with ( + patch( + "signalwire.mcp_gateway.gateway_service.make_server", + return_value=mock_server, + ), + patch("signalwire.mcp_gateway.gateway_service.signal"), + patch("os.path.exists", return_value=False), + patch.object(self.gateway, "shutdown") as mock_shutdown, + ): self.gateway.run() mock_shutdown.assert_called() @@ -1254,10 +1350,14 @@ def test_run_registers_signal_handlers(self) -> None: mock_server = MagicMock() mock_server.serve_forever.side_effect = KeyboardInterrupt() - with patch("signalwire.mcp_gateway.gateway_service.make_server", return_value=mock_server), \ - patch("signalwire.mcp_gateway.gateway_service.signal") as mock_signal_mod, \ - patch("os.path.exists", return_value=False): - + with ( + patch( + "signalwire.mcp_gateway.gateway_service.make_server", + return_value=mock_server, + ), + patch("signalwire.mcp_gateway.gateway_service.signal") as mock_signal_mod, + patch("os.path.exists", return_value=False), + ): self.gateway.run() # SIGTERM and SIGINT handlers should be registered @@ -1275,10 +1375,14 @@ def test_run_uses_config_host_and_port(self) -> None: mock_server = MagicMock() mock_server.serve_forever.side_effect = KeyboardInterrupt() - with patch("signalwire.mcp_gateway.gateway_service.make_server", return_value=mock_server) as mock_make, \ - patch("signalwire.mcp_gateway.gateway_service.signal"), \ - patch("os.path.exists", return_value=False): - + with ( + patch( + "signalwire.mcp_gateway.gateway_service.make_server", + return_value=mock_server, + ) as mock_make, + patch("signalwire.mcp_gateway.gateway_service.signal"), + patch("os.path.exists", return_value=False), + ): gateway.run() assert mock_make.call_args[0][0] == "127.0.0.1" assert mock_make.call_args[0][1] == 9999 @@ -1288,6 +1392,7 @@ def test_run_uses_config_host_and_port(self) -> None: # Tests: Error Handler # =================================================================== + class TestErrorHandler: """Tests for the generic error handler.""" @@ -1316,13 +1421,14 @@ def test_unknown_route_returns_error(self) -> None: # Tests: Edge Cases # =================================================================== + class TestEdgeCases: """Miscellaneous edge case tests.""" def test_multiple_gateway_instances_are_independent(self) -> None: """Two gateways do not share state.""" - gw1, mocks1 = _create_gateway() - gw2, mocks2 = _create_gateway() + gw1, _mocks1 = _create_gateway() + gw2, _mocks2 = _create_gateway() assert gw1.app is not gw2.app assert gw1.mcp_manager is not gw2.mcp_manager @@ -1347,21 +1453,22 @@ def test_config_without_logging_section(self) -> None: assert "logging" not in gateway.config assert gateway.app is not None - def test_config_with_log_file(self) -> None: + def test_config_with_log_file(self, tmp_path: Path) -> None: """When [logging].file is set, a FileHandler must be installed on the root logger pointed at that file path.""" + log_file = str(tmp_path / "test_gateway.log") config = _minimal_config() - config["logging"] = {"level": "DEBUG", "file": "/tmp/test_gateway.log"} + config["logging"] = {"level": "DEBUG", "file": log_file} # Create a real-ish mock handler that won't break the logging system mock_handler = MagicMock(spec=logging.FileHandler) mock_handler.level = logging.DEBUG mock_handler.formatter = None mock_handler.filters = [] with patch("logging.FileHandler", return_value=mock_handler) as mock_fh: - gateway, _ = _create_gateway(config) + _gateway, _ = _create_gateway(config) # FileHandler was constructed targeting the configured path. assert mock_fh.call_count >= 1 - assert mock_fh.call_args[0][0] == "/tmp/test_gateway.log" + assert mock_fh.call_args[0][0] == log_file # Clean up: remove the mock handler from the root logger to avoid # poisoning other tests root = logging.getLogger() @@ -1403,7 +1510,7 @@ def test_call_tool_with_special_chars_in_arguments(self) -> None: def test_call_tool_with_zero_timeout(self) -> None: """Timeout of 0 should be rejected.""" - gateway, mocks = _create_gateway() + gateway, _mocks = _create_gateway() client = gateway.app.test_client() payload = { diff --git a/tests/unit/mcp_gateway/test_mcp_manager.py b/tests/unit/mcp_gateway/test_mcp_manager.py index 6af599af..48cb651b 100644 --- a/tests/unit/mcp_gateway/test_mcp_manager.py +++ b/tests/unit/mcp_gateway/test_mcp_manager.py @@ -13,12 +13,13 @@ import os import json import queue +import tempfile import threading import subprocess -import time +from pathlib import Path from typing import Any import pytest -from unittest.mock import Mock, patch, MagicMock, PropertyMock, call +from unittest.mock import Mock, patch, MagicMock # Skip the entire module when Flask is not installed (mcp_gateway.__init__ imports flask) pytest.importorskip("flask", reason="flask is required for MCP Gateway tests") @@ -29,6 +30,14 @@ MCPManager, ) +# Sandbox base for the tests below. These tests never actually create the +# sandbox — Path.mkdir / shutil.rmtree are patched throughout — but the value +# is threaded through MCPClient and asserted on, so it must be a real, +# process-unique path rather than a hardcoded shared one. Class helpers +# (_make_client) need it at import time, where the tmp_path fixture is not +# reachable. +_SANDBOX_BASE = tempfile.mkdtemp(prefix="sw_mcp_sandbox_") + # --------------------------------------------------------------------------- # MCPService dataclass tests @@ -76,9 +85,7 @@ def test_enabled_defaults_to_true(self) -> None: def test_enabled_can_be_false(self) -> None: """The enabled flag can be set to False.""" - service = MCPService( - name="s", command=["cmd"], description="d", enabled=False - ) + service = MCPService(name="s", command=["cmd"], description="d", enabled=False) assert service.enabled is False def test_hash_based_on_name(self) -> None: @@ -132,9 +139,10 @@ def test_defaults(self) -> None: def test_custom_sandbox_base_dir(self) -> None: """MCPClient should accept a custom sandbox_base_dir.""" service = MCPService(name="svc", command=["cmd"], description="desc") - client = MCPClient(service, sandbox_base_dir="/tmp/my_sandbox") + custom_base = str(Path(_SANDBOX_BASE) / "my_sandbox") + client = MCPClient(service, sandbox_base_dir=custom_base) - assert client.sandbox_base_dir == "/tmp/my_sandbox" + assert client.sandbox_base_dir == custom_base class TestMCPClientSetupSandboxEnv: @@ -147,23 +155,24 @@ def _make_client(self, sandbox_config: dict[str, Any] | None = None) -> MCPClien description="", sandbox_config=sandbox_config, ) - return MCPClient(svc, sandbox_base_dir="/tmp/sandbox_test") + return MCPClient(svc, sandbox_base_dir=_SANDBOX_BASE) - @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_sandboxing_disabled_returns_full_env(self, mock_makedirs: MagicMock) -> None: + @patch("signalwire.mcp_gateway.mcp_manager.Path.mkdir") + def test_sandboxing_disabled_returns_full_env(self, mock_mkdir: MagicMock) -> None: """When sandbox is disabled, the full environment is returned.""" client = self._make_client(sandbox_config={"enabled": False}) env, cwd = client._setup_sandbox_env() # Should not create sandbox dir - mock_makedirs.assert_not_called() + mock_mkdir.assert_not_called() # Should return roughly the current environment assert "PATH" in env - assert cwd == os.getcwd() + # Production returns a str (``str(Path.cwd())``), so compare as a str. + assert cwd == str(Path.cwd()) - @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_sandbox_enabled_restricted_env(self, mock_makedirs: MagicMock) -> None: + @patch("signalwire.mcp_gateway.mcp_manager.Path.mkdir", autospec=True) + def test_sandbox_enabled_restricted_env(self, mock_mkdir: MagicMock) -> None: """When sandbox is enabled with restricted_env, a minimal env is created.""" client = self._make_client( sandbox_config={ @@ -175,7 +184,12 @@ def test_sandbox_enabled_restricted_env(self, mock_makedirs: MagicMock) -> None: env, cwd = client._setup_sandbox_env() - mock_makedirs.assert_called_once() + assert client.sandbox_dir is not None + mock_mkdir.assert_called_once_with( + Path(client.sandbox_dir), parents=True, exist_ok=True + ) + # No working_dir is configured, so the cwd falls back to the process cwd. + assert cwd == str(Path.cwd()) # Restricted env should have limited keys assert "PATH" in env assert "HOME" in env @@ -200,6 +214,8 @@ def test_sandbox_enabled_unrestricted_env(self, mock_makedirs: MagicMock) -> Non with patch.dict(os.environ, {"LD_PRELOAD": "evil.so"}, clear=False): env, cwd = client._setup_sandbox_env() + # No working_dir is configured, so the cwd falls back to the process cwd. + assert cwd == str(Path.cwd()) # LD_PRELOAD should be stripped even with unrestricted assert "LD_PRELOAD" not in env # HOME should be overridden to sandbox dir @@ -232,7 +248,9 @@ def test_sandbox_working_dir_from_config(self, mock_makedirs: MagicMock) -> None assert cwd == "/custom/dir" @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_sandbox_disabled_working_dir_from_config(self, mock_makedirs: MagicMock) -> None: + def test_sandbox_disabled_working_dir_from_config( + self, mock_makedirs: MagicMock + ) -> None: """When sandboxing disabled, working_dir from config is still honoured.""" client = self._make_client( sandbox_config={"enabled": False, "working_dir": "/other/dir"} @@ -290,8 +308,7 @@ class TestMCPClientCallMethod: def _make_client(self) -> MCPClient: service = MCPService(name="svc", command=["cmd"], description="") - client = MCPClient(service) - return client + return MCPClient(service) def test_call_method_raises_when_shutting_down(self) -> None: """call_method should raise RuntimeError when shutdown is set.""" @@ -372,19 +389,23 @@ def test_call_method_timeout(self) -> None: # _send_message does nothing (no response will arrive) client._send_message = Mock() # type: ignore[method-assign] # mock - with pytest.raises(TimeoutError, match="Timeout"): + with ( + pytest.raises(TimeoutError, match="Timeout"), # Use a very short timeout by patching the Event.wait - with patch.object(threading.Event, "wait", return_value=False): - client.call_method("slow_method", {}) + patch.object(threading.Event, "wait", return_value=False), + ): + client.call_method("slow_method", {}) def test_call_method_cleans_up_on_timeout(self) -> None: """Pending request should be removed on timeout.""" client = self._make_client() client._send_message = Mock() # type: ignore[method-assign] # mock - with patch.object(threading.Event, "wait", return_value=False): - with pytest.raises(TimeoutError): - client.call_method("slow_method", {}) + with ( + patch.object(threading.Event, "wait", return_value=False), + pytest.raises(TimeoutError), + ): + client.call_method("slow_method", {}) # Pending requests should be empty after timeout cleanup assert len(client.pending_requests) == 0 @@ -434,7 +455,9 @@ class TestMCPClientStart: @patch("signalwire.mcp_gateway.mcp_manager.subprocess.Popen") @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_start_success(self, mock_makedirs: MagicMock, mock_popen: MagicMock) -> None: + def test_start_success( + self, mock_makedirs: MagicMock, mock_popen: MagicMock + ) -> None: """start() should return True when initialization and tool listing succeed.""" service = MCPService( name="svc", @@ -462,7 +485,9 @@ def test_start_success(self, mock_makedirs: MagicMock, mock_popen: MagicMock) -> @patch("signalwire.mcp_gateway.mcp_manager.subprocess.Popen") @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_start_fails_on_initialize(self, mock_makedirs: MagicMock, mock_popen: MagicMock) -> None: + def test_start_fails_on_initialize( + self, mock_makedirs: MagicMock, mock_popen: MagicMock + ) -> None: """start() should return False when _initialize fails.""" service = MCPService( name="svc", @@ -489,7 +514,9 @@ def test_start_fails_on_initialize(self, mock_makedirs: MagicMock, mock_popen: M @patch("signalwire.mcp_gateway.mcp_manager.subprocess.Popen") @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_start_fails_on_popen_exception(self, mock_makedirs: MagicMock, mock_popen: MagicMock) -> None: + def test_start_fails_on_popen_exception( + self, mock_makedirs: MagicMock, mock_popen: MagicMock + ) -> None: """start() should return False when Popen raises an exception.""" service = MCPService( name="svc", @@ -506,7 +533,9 @@ def test_start_fails_on_popen_exception(self, mock_makedirs: MagicMock, mock_pop @patch("signalwire.mcp_gateway.mcp_manager.subprocess.Popen") @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_start_creates_reader_thread(self, mock_makedirs: MagicMock, mock_popen: MagicMock) -> None: + def test_start_creates_reader_thread( + self, mock_makedirs: MagicMock, mock_popen: MagicMock + ) -> None: """start() should launch a reader thread.""" service = MCPService( name="svc", @@ -523,7 +552,9 @@ def test_start_creates_reader_thread(self, mock_makedirs: MagicMock, mock_popen: client._initialize = Mock(return_value=True) # type: ignore[method-assign] # mock client._list_tools = Mock(return_value=[]) # type: ignore[method-assign] # mock - with patch("signalwire.mcp_gateway.mcp_manager.threading.Thread") as mock_thread_cls: + with patch( + "signalwire.mcp_gateway.mcp_manager.threading.Thread" + ) as mock_thread_cls: mock_thread_instance = Mock() mock_thread_cls.return_value = mock_thread_instance @@ -556,7 +587,7 @@ def test_stop_terminates_running_process(self) -> None: client.stop() assert client._shutdown.is_set() - client.process is None + assert client.process is None def test_stop_force_kills_on_timeout(self) -> None: """stop() should force kill if terminate doesn't work in time.""" @@ -586,17 +617,20 @@ def test_stop_force_kills_on_timeout(self) -> None: def test_stop_cleans_up_sandbox_dir(self, mock_rmtree: MagicMock) -> None: """stop() should remove the sandbox directory if it exists.""" service = MCPService( - name="svc", command=["cmd"], description="", + name="svc", + command=["cmd"], + description="", sandbox_config={"enabled": True}, ) + sandbox_dir = str(Path(_SANDBOX_BASE) / "mcp_svc_123") client = MCPClient(service) client.process = None # already stopped - client.sandbox_dir = "/tmp/sandbox_test/mcp_svc_123" + client.sandbox_dir = sandbox_dir - with patch("signalwire.mcp_gateway.mcp_manager.os.path.exists", return_value=True): + with patch("signalwire.mcp_gateway.mcp_manager.Path.exists", return_value=True): client.stop() - mock_rmtree.assert_called_once_with("/tmp/sandbox_test/mcp_svc_123") + mock_rmtree.assert_called_once_with(sandbox_dir) def test_stop_when_no_process(self) -> None: """stop() should not raise when process is None.""" @@ -827,7 +861,9 @@ def test_preexec_sets_resource_limits(self, mock_setrlimit: MagicMock) -> None: assert mock_setrlimit.call_count == 4 @patch("signalwire.mcp_gateway.mcp_manager.resource.setrlimit") - def test_preexec_skips_resource_limits_when_disabled(self, mock_setrlimit: MagicMock) -> None: + def test_preexec_skips_resource_limits_when_disabled( + self, mock_setrlimit: MagicMock + ) -> None: """_sandbox_preexec should skip resource limits when disabled.""" service = MCPService( name="svc", @@ -842,7 +878,9 @@ def test_preexec_skips_resource_limits_when_disabled(self, mock_setrlimit: Magic mock_setrlimit.assert_not_called() @patch("signalwire.mcp_gateway.mcp_manager.resource.setrlimit") - def test_preexec_handles_resource_limit_errors(self, mock_setrlimit: MagicMock) -> None: + def test_preexec_handles_resource_limit_errors( + self, mock_setrlimit: MagicMock + ) -> None: """_sandbox_preexec should not raise on resource limit errors.""" mock_setrlimit.side_effect = OSError("Not permitted") @@ -866,8 +904,8 @@ def test_preexec_handles_resource_limit_errors(self, mock_setrlimit: MagicMock) class TestMCPManagerInit: """Tests for MCPManager.__init__.""" - @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_init_with_empty_config(self, mock_makedirs: MagicMock) -> None: + @patch("signalwire.mcp_gateway.mcp_manager.Path.mkdir", autospec=True) + def test_init_with_empty_config(self, mock_mkdir: MagicMock) -> None: """MCPManager should initialize with an empty config.""" config: dict[str, Any] = {} manager = MCPManager(config) @@ -876,19 +914,24 @@ def test_init_with_empty_config(self, mock_makedirs: MagicMock) -> None: assert manager.services == {} assert manager.clients == {} assert manager.sandbox_base_dir == "./sandbox" - mock_makedirs.assert_called_once_with("./sandbox", exist_ok=True) + # autospec keeps the receiver, so the directory being created is asserted. + mock_mkdir.assert_called_once_with( + Path("./sandbox"), parents=True, exist_ok=True + ) - @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_init_with_custom_sandbox_dir(self, mock_makedirs: MagicMock) -> None: + @patch("signalwire.mcp_gateway.mcp_manager.Path.mkdir", autospec=True) + def test_init_with_custom_sandbox_dir(self, mock_mkdir: MagicMock) -> None: """MCPManager should use sandbox_dir from session config.""" config = {"session": {"sandbox_dir": "/custom/sandbox"}} manager = MCPManager(config) assert manager.sandbox_base_dir == "/custom/sandbox" - mock_makedirs.assert_called_once_with("/custom/sandbox", exist_ok=True) + mock_mkdir.assert_called_once_with( + Path("/custom/sandbox"), parents=True, exist_ok=True + ) - @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_init_loads_services(self, mock_makedirs: MagicMock) -> None: + @patch("signalwire.mcp_gateway.mcp_manager.Path.mkdir") + def test_init_loads_services(self, mock_mkdir: MagicMock) -> None: """MCPManager should load services from config on init.""" config = { "services": { @@ -939,7 +982,9 @@ class TestMCPManagerLoadServices: """Tests for MCPManager._load_services.""" @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_load_services_creates_mcp_service_objects(self, mock_makedirs: MagicMock) -> None: + def test_load_services_creates_mcp_service_objects( + self, mock_makedirs: MagicMock + ) -> None: """Loaded services should be MCPService instances with correct attributes.""" config = { "services": { @@ -994,9 +1039,7 @@ class TestMCPManagerGetService: def test_get_existing_service(self, mock_makedirs: MagicMock) -> None: """get_service should return the MCPService for a known name.""" config = { - "services": { - "known": {"command": ["cmd"], "description": "known service"} - } + "services": {"known": {"command": ["cmd"], "description": "known service"}} } manager = MCPManager(config) @@ -1083,13 +1126,11 @@ def test_create_client_disabled_service(self, mock_makedirs: MagicMock) -> None: @patch("signalwire.mcp_gateway.mcp_manager.MCPClient") @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_create_client_success(self, mock_makedirs: MagicMock, mock_client_cls: MagicMock) -> None: + def test_create_client_success( + self, mock_makedirs: MagicMock, mock_client_cls: MagicMock + ) -> None: """create_client should return a started MCPClient and track it.""" - config = { - "services": { - "my_svc": {"command": ["echo"], "description": "test"} - } - } + config = {"services": {"my_svc": {"command": ["echo"], "description": "test"}}} manager = MCPManager(config) mock_client_instance = Mock() @@ -1105,12 +1146,12 @@ def test_create_client_success(self, mock_makedirs: MagicMock, mock_client_cls: @patch("signalwire.mcp_gateway.mcp_manager.MCPClient") @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_create_client_start_failure(self, mock_makedirs: MagicMock, mock_client_cls: MagicMock) -> None: + def test_create_client_start_failure( + self, mock_makedirs: MagicMock, mock_client_cls: MagicMock + ) -> None: """create_client should raise RuntimeError when client.start() fails.""" config = { - "services": { - "my_svc": {"command": ["bad_cmd"], "description": "test"} - } + "services": {"my_svc": {"command": ["bad_cmd"], "description": "test"}} } manager = MCPManager(config) @@ -1123,13 +1164,11 @@ def test_create_client_start_failure(self, mock_makedirs: MagicMock, mock_client @patch("signalwire.mcp_gateway.mcp_manager.MCPClient") @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_create_client_tracks_with_unique_key(self, mock_makedirs: MagicMock, mock_client_cls: MagicMock) -> None: + def test_create_client_tracks_with_unique_key( + self, mock_makedirs: MagicMock, mock_client_cls: MagicMock + ) -> None: """Each created client should get a unique key in the clients dict.""" - config = { - "services": { - "svc": {"command": ["cmd"], "description": ""} - } - } + config = {"services": {"svc": {"command": ["cmd"], "description": ""}}} manager = MCPManager(config) client1 = Mock() @@ -1148,25 +1187,26 @@ class TestMCPManagerGetServiceTools: """Tests for MCPManager.get_service_tools.""" @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_get_service_tools_returns_tools_and_cleans_up(self, mock_makedirs: MagicMock) -> None: + def test_get_service_tools_returns_tools_and_cleans_up( + self, mock_makedirs: MagicMock + ) -> None: """get_service_tools should return tools and clean up the temp client.""" - config = { - "services": { - "svc": {"command": ["cmd"], "description": ""} - } - } + config = {"services": {"svc": {"command": ["cmd"], "description": ""}}} manager = MCPManager(config) mock_client = Mock() mock_client.start.return_value = True mock_client.get_tools.return_value = [{"name": "tool_a"}] - with patch.object(manager, "create_client", return_value=mock_client) as mock_create: + with patch.object( + manager, "create_client", return_value=mock_client + ) as mock_create: # We need to simulate what create_client does - track the client def side_effect(name: str) -> Mock: key = f"{name}_{id(mock_client)}" manager.clients[key] = mock_client return mock_client + mock_create.side_effect = side_effect tools = manager.get_service_tools("svc") @@ -1177,24 +1217,26 @@ def side_effect(name: str) -> Mock: assert len(manager.clients) == 0 @patch("signalwire.mcp_gateway.mcp_manager.os.makedirs") - def test_get_service_tools_cleans_up_on_error(self, mock_makedirs: MagicMock) -> None: + def test_get_service_tools_cleans_up_on_error( + self, mock_makedirs: MagicMock + ) -> None: """get_service_tools should clean up even when get_tools raises.""" - config = { - "services": { - "svc": {"command": ["cmd"], "description": ""} - } - } + config = {"services": {"svc": {"command": ["cmd"], "description": ""}}} manager = MCPManager(config) mock_client = Mock() mock_client.start.return_value = True mock_client.get_tools.side_effect = Exception("oops") - with patch.object(manager, "create_client", return_value=mock_client) as mock_create: + with patch.object( + manager, "create_client", return_value=mock_client + ) as mock_create: + def side_effect(name: str) -> Mock: key = f"{name}_{id(mock_client)}" manager.clients[key] = mock_client return mock_client + mock_create.side_effect = side_effect with pytest.raises(Exception, match="oops"): @@ -1220,11 +1262,15 @@ def test_validate_services_all_pass(self, mock_makedirs: MagicMock) -> None: mock_client = Mock() mock_client.start.return_value = True - with patch.object(manager, "create_client", return_value=mock_client) as mock_create: + with patch.object( + manager, "create_client", return_value=mock_client + ) as mock_create: + def side_effect(name: str) -> Mock: key = f"{name}_{id(mock_client)}" manager.clients[key] = mock_client return mock_client + mock_create.side_effect = side_effect results = manager.validate_services() diff --git a/tests/unit/pom/test_pom_object_model.py b/tests/unit/pom/test_pom_object_model.py index 8c4f2a8b..c7b28695 100644 --- a/tests/unit/pom/test_pom_object_model.py +++ b/tests/unit/pom/test_pom_object_model.py @@ -5,9 +5,6 @@ PromptObjectModel API that ports must mirror. """ -import json -import pytest - from signalwire.pom.pom import PromptObjectModel, Section diff --git a/tests/unit/pom/test_pom_render_parity.py b/tests/unit/pom/test_pom_render_parity.py index dd023a32..2f033241 100644 --- a/tests/unit/pom/test_pom_render_parity.py +++ b/tests/unit/pom/test_pom_render_parity.py @@ -19,18 +19,14 @@ - (others as ported) """ -import json -import textwrap - -import yaml - -from signalwire.pom.pom import PromptObjectModel, Section +from signalwire.pom.pom import PromptObjectModel # ---------------------------------------------------------------------------- # Empty POM # ---------------------------------------------------------------------------- + class TestEmptyPom: def test_empty_render_markdown_is_empty_string(self) -> None: pom = PromptObjectModel() @@ -55,6 +51,7 @@ def test_empty_to_yaml(self) -> None: # Single section with title + body # ---------------------------------------------------------------------------- + class TestSimpleSection: def test_render_markdown_exact(self) -> None: pom = PromptObjectModel() @@ -67,12 +64,12 @@ def test_render_xml_exact(self) -> None: pom.add_section(title="Greeting", body="Hello world") expected = ( '\n' - '\n' - '
\n' - ' Greeting\n' - ' Hello world\n' - '
\n' - '
' + "\n" + "
\n" + " Greeting\n" + " Hello world\n" + "
\n" + "
" ) assert pom.render_xml() == expected @@ -81,30 +78,33 @@ def test_render_xml_exact(self) -> None: # Section with bullets # ---------------------------------------------------------------------------- + class TestBullets: def test_render_markdown_with_bullets(self) -> None: pom = PromptObjectModel() - pom.add_section(title="Goals", body="Be helpful", - bullets=["Be concise", "Be clear"]) + pom.add_section( + title="Goals", body="Be helpful", bullets=["Be concise", "Be clear"] + ) expected = "## Goals\n\nBe helpful\n\n- Be concise\n- Be clear\n" assert pom.render_markdown() == expected def test_render_xml_with_bullets(self) -> None: pom = PromptObjectModel() - pom.add_section(title="Goals", body="Be helpful", - bullets=["Be concise", "Be clear"]) + pom.add_section( + title="Goals", body="Be helpful", bullets=["Be concise", "Be clear"] + ) expected = ( '\n' - '\n' - '
\n' - ' Goals\n' - ' Be helpful\n' - ' \n' - ' Be concise\n' - ' Be clear\n' - ' \n' - '
\n' - '
' + "\n" + "
\n" + " Goals\n" + " Be helpful\n" + " \n" + " Be concise\n" + " Be clear\n" + " \n" + "
\n" + "
" ) assert pom.render_xml() == expected @@ -113,6 +113,7 @@ def test_render_xml_with_bullets(self) -> None: # Subsections # ---------------------------------------------------------------------------- + class TestSubsections: def test_render_markdown_with_subsection(self) -> None: pom = PromptObjectModel() @@ -127,22 +128,22 @@ def test_render_xml_with_subsection(self) -> None: s.add_subsection(title="Sub1", body="Sub1 body", bullets=["a", "b"]) expected = ( '\n' - '\n' - '
\n' - ' Top\n' - ' Top body\n' - ' \n' - '
\n' - ' Sub1\n' - ' Sub1 body\n' - ' \n' - ' a\n' - ' b\n' - ' \n' - '
\n' - '
\n' - '
\n' - '
' + "\n" + "
\n" + " Top\n" + " Top body\n" + " \n" + "
\n" + " Sub1\n" + " Sub1 body\n" + " \n" + " a\n" + " b\n" + " \n" + "
\n" + "
\n" + "
\n" + "
" ) assert pom.render_xml() == expected @@ -151,6 +152,7 @@ def test_render_xml_with_subsection(self) -> None: # Numbered top-level sections # ---------------------------------------------------------------------------- + class TestNumberedSections: def test_render_markdown_numbered_propagates_to_siblings(self) -> None: # Once any sibling is numbered=True, all siblings (without explicit @@ -167,16 +169,16 @@ def test_render_xml_numbered_propagates(self) -> None: pom.add_section(title="S2", body="b2") expected = ( '\n' - '\n' - '
\n' - ' 1. S1\n' - ' b1\n' - '
\n' - '
\n' - ' 2. S2\n' - ' b2\n' - '
\n' - '
' + "\n" + "
\n" + " 1. S1\n" + " b1\n" + "
\n" + "
\n" + " 2. S2\n" + " b2\n" + "
\n" + "
" ) assert pom.render_xml() == expected @@ -185,6 +187,7 @@ def test_render_xml_numbered_propagates(self) -> None: # Numbered bullets # ---------------------------------------------------------------------------- + class TestNumberedBullets: def test_render_markdown_numbered_bullets(self) -> None: pom = PromptObjectModel() @@ -197,15 +200,15 @@ def test_render_xml_numbered_bullets_use_id_attr(self) -> None: pom.add_section(title="X", bullets=["one", "two"], numberedBullets=True) expected = ( '\n' - '\n' - '
\n' - ' X\n' - ' \n' + "\n" + "
\n" + " X\n" + " \n" ' one\n' ' two\n' - ' \n' - '
\n' - '
' + "
\n" + "
\n" + "
" ) assert pom.render_xml() == expected @@ -214,27 +217,28 @@ def test_render_xml_numbered_bullets_use_id_attr(self) -> None: # JSON / YAML round-trip with exact key order # ---------------------------------------------------------------------------- + class TestSerialization: def test_to_json_exact_shape(self) -> None: pom = PromptObjectModel() s = pom.add_section(title="A", body="ab") s.add_subsection(title="A1", body="a1b", bullets=["x"]) expected = ( - '[\n' - ' {\n' + "[\n" + " {\n" ' "title": "A",\n' ' "body": "ab",\n' ' "subsections": [\n' - ' {\n' + " {\n" ' "title": "A1",\n' ' "body": "a1b",\n' ' "bullets": [\n' ' "x"\n' - ' ]\n' - ' }\n' - ' ]\n' - ' }\n' - ']' + " ]\n" + " }\n" + " ]\n" + " }\n" + "]" ) assert pom.to_json() == expected @@ -274,6 +278,7 @@ def test_from_yaml_round_trip_preserves_structure(self) -> None: # find_section recursion # ---------------------------------------------------------------------------- + class TestFindSection: def test_find_section_top_level(self) -> None: pom = PromptObjectModel() @@ -301,6 +306,7 @@ def test_find_section_returns_none_for_missing(self) -> None: # add_pom_as_subsection # ---------------------------------------------------------------------------- + class TestAddPomAsSubsection: def test_add_pom_to_existing_section_by_title(self) -> None: host = PromptObjectModel() diff --git a/tests/unit/prefabs/test_concierge.py b/tests/unit/prefabs/test_concierge.py index c6ea99de..d0e502e1 100644 --- a/tests/unit/prefabs/test_concierge.py +++ b/tests/unit/prefabs/test_concierge.py @@ -10,9 +10,8 @@ """ import pytest -import json from typing import Any -from unittest.mock import Mock, patch, MagicMock, call +from unittest.mock import Mock, patch, MagicMock from signalwire.prefabs.concierge import ConciergeAgent @@ -40,22 +39,25 @@ def _make_concierge(**overrides: Any) -> tuple[ConciergeAgent, MagicMock]: """ # Stub the methods that _setup_concierge_agent calls on `self` # so that assertions can be made against them. - with patch( - "signalwire.prefabs.concierge.AgentBase.__init__", return_value=None - ) as mock_init, patch.multiple( - ConciergeAgent, - prompt_add_section=Mock(), - set_post_prompt=Mock(), - add_hints=Mock(), - set_params=Mock(), - set_global_data=Mock(), - set_native_functions=Mock(), + with ( + patch( + "signalwire.prefabs.concierge.AgentBase.__init__", return_value=None + ) as mock_init, + patch.multiple( + ConciergeAgent, + prompt_add_section=Mock(), + set_post_prompt=Mock(), + add_hints=Mock(), + set_params=Mock(), + set_global_data=Mock(), + set_native_functions=Mock(), + ), ): - kwargs: dict[str, Any] = dict( - venue_name=VENUE_NAME, - services=SERVICES, - amenities=AMENITIES, - ) + kwargs: dict[str, Any] = { + "venue_name": VENUE_NAME, + "services": SERVICES, + "amenities": AMENITIES, + } kwargs.update(overrides) agent = ConciergeAgent(**kwargs) @@ -86,7 +88,7 @@ class TestConciergeInitialization: def test_super_init_called_with_defaults(self) -> None: """super().__init__ receives name, route, and use_pom.""" - agent, mock_init = _make_concierge() + _agent, mock_init = _make_concierge() mock_init.assert_called_once() _, kwargs = mock_init.call_args @@ -96,7 +98,7 @@ def test_super_init_called_with_defaults(self) -> None: def test_super_init_custom_name_and_route(self) -> None: """Custom name and route are forwarded to AgentBase.""" - agent, mock_init = _make_concierge(name="lobby", route="/lobby") + _agent, mock_init = _make_concierge(name="lobby", route="/lobby") _, kwargs = mock_init.call_args assert kwargs["name"] == "lobby" @@ -104,7 +106,7 @@ def test_super_init_custom_name_and_route(self) -> None: def test_extra_kwargs_forwarded_to_super(self) -> None: """Arbitrary **kwargs are forwarded to AgentBase.__init__.""" - agent, mock_init = _make_concierge(host="0.0.0.0", port=9090) + _agent, mock_init = _make_concierge(host="0.0.0.0", port=9090) _, kwargs = mock_init.call_args assert kwargs["host"] == "0.0.0.0" @@ -137,7 +139,10 @@ def test_default_special_instructions_empty(self) -> None: assert agent.special_instructions == [] def test_custom_special_instructions(self) -> None: - instructions = ["Always upsell the premium package", "Mention the loyalty program"] + instructions = [ + "Always upsell the premium package", + "Mention the loyalty program", + ] agent, _ = _make_concierge(special_instructions=instructions) assert agent.special_instructions == instructions @@ -241,7 +246,7 @@ def test_amenities_subsection_body_contains_details(self) -> None: calls = self.agent.prompt_add_section.call_args_list # type: ignore[attr-defined] # mock attr amen_calls = [c for c in calls if c[0][0] == "Amenities"] subsections = amen_calls[0][1]["subsections"] - pool_sub = [s for s in subsections if s["title"] == "Pool"][0] + pool_sub = next(s for s in subsections if s["title"] == "Pool") assert "7 AM - 10 PM" in pool_sub["body"] assert "2nd Floor" in pool_sub["body"] @@ -369,7 +374,10 @@ def test_known_service_case_insensitive(self) -> None: assert isinstance(result, FunctionResult) # The lowered input "room service" matches "room service" in SERVICES assert "room service" in result.response.lower() - assert "available" in result.response.lower() or "reservation" in result.response.lower() + assert ( + "available" in result.response.lower() + or "reservation" in result.response.lower() + ) def test_unknown_service_returns_error(self) -> None: from signalwire.core.function_result import FunctionResult @@ -379,7 +387,10 @@ def test_unknown_service_returns_error(self) -> None: raw_data={}, ) assert isinstance(result, FunctionResult) - assert "sorry" in result.response.lower() or "don't offer" in result.response.lower() + assert ( + "sorry" in result.response.lower() + or "don't offer" in result.response.lower() + ) assert VENUE_NAME in result.response # Should list available services for svc in SERVICES: @@ -442,21 +453,30 @@ def test_known_amenity_gym(self) -> None: def test_unknown_location_returns_fallback(self) -> None: result = self.agent.get_directions({"location": "helipad"}, raw_data={}) - assert "front desk" in result.response.lower() or "don't have" in result.response.lower() + assert ( + "front desk" in result.response.lower() + or "don't have" in result.response.lower() + ) def test_amenity_without_location_field(self) -> None: """If an amenity exists but has no 'location' key, fallback is used.""" self.agent.amenities["sauna"] = {"hours": "10 AM - 8 PM"} # no "location" result = self.agent.get_directions({"location": "sauna"}, raw_data={}) # Should hit the else branch because "location" not in details - assert "don't have" in result.response.lower() or "front desk" in result.response.lower() + assert ( + "don't have" in result.response.lower() + or "front desk" in result.response.lower() + ) def test_empty_location_arg(self) -> None: result = self.agent.get_directions({}, raw_data={}) # Empty/missing location must hit the fallback branch — there is # no amenity called "", so we expect the same fallback wording as # an unknown location. - assert "front desk" in result.response.lower() or "don't have" in result.response.lower() + assert ( + "front desk" in result.response.lower() + or "don't have" in result.response.lower() + ) def test_location_case_sensitivity(self) -> None: """Location lookup is lowercased; amenity keys in our fixture are lowercase.""" @@ -505,13 +525,17 @@ def test_none_summary_no_output(self, capsys: pytest.CaptureFixture[str]) -> Non captured = capsys.readouterr() assert captured.out == "" - def test_empty_string_summary_no_output(self, capsys: pytest.CaptureFixture[str]) -> None: + def test_empty_string_summary_no_output( + self, capsys: pytest.CaptureFixture[str] + ) -> None: """Empty string is falsy, so no output should be produced.""" self.agent.on_summary("") # type: ignore[arg-type] # intentional: exercises non-dict/str runtime branch captured = capsys.readouterr() assert captured.out == "" - def test_empty_dict_summary_treated_as_falsy(self, capsys: pytest.CaptureFixture[str]) -> None: + def test_empty_dict_summary_treated_as_falsy( + self, capsys: pytest.CaptureFixture[str] + ) -> None: """An empty dict is falsy; no output expected.""" self.agent.on_summary({}) captured = capsys.readouterr() @@ -523,7 +547,9 @@ def test_raw_data_accepted(self, capsys: pytest.CaptureFixture[str]) -> None: captured = capsys.readouterr() assert "test" in captured.out - def test_exception_during_summary_processing(self, capsys: pytest.CaptureFixture[str]) -> None: + def test_exception_during_summary_processing( + self, capsys: pytest.CaptureFixture[str] + ) -> None: """If json.dumps raises, the except branch prints the error.""" bad_summary = Mock() bad_summary.__bool__ = Mock(return_value=True) @@ -531,7 +557,10 @@ def test_exception_during_summary_processing(self, capsys: pytest.CaptureFixture # to the else branch which calls print(f"... {summary}"). That # won't raise, so let's force isinstance to return True and make # json.dumps fail. - with patch("signalwire.prefabs.concierge.json.dumps", side_effect=TypeError("not serializable")): + with patch( + "signalwire.prefabs.concierge.json.dumps", + side_effect=TypeError("not serializable"), + ): self.agent.on_summary({"key": "value"}) # type: ignore[arg-type] # intentional: exercises non-dict/str runtime branch captured = capsys.readouterr() assert "Error processing summary" in captured.out @@ -625,14 +654,20 @@ def test_check_availability_with_empty_services(self) -> None: {"service": "anything", "date": "2025-01-01", "time": "10:00"}, raw_data={}, ) - assert "sorry" in result.response.lower() or "don't offer" in result.response.lower() + assert ( + "sorry" in result.response.lower() + or "don't offer" in result.response.lower() + ) def test_get_directions_with_empty_amenities(self) -> None: """When amenities dict is empty, every location is unknown.""" agent = _make_bare_concierge() agent.amenities = {} result = agent.get_directions({"location": "pool"}, raw_data={}) - assert "don't have" in result.response.lower() or "front desk" in result.response.lower() + assert ( + "don't have" in result.response.lower() + or "front desk" in result.response.lower() + ) def test_hours_of_operation_none_gets_default(self) -> None: """Passing None for hours_of_operation uses the default.""" diff --git a/tests/unit/prefabs/test_survey.py b/tests/unit/prefabs/test_survey.py index cdffeb37..cfffe569 100644 --- a/tests/unit/prefabs/test_survey.py +++ b/tests/unit/prefabs/test_survey.py @@ -10,9 +10,8 @@ """ import pytest -import json from typing import Any -from unittest.mock import Mock, patch, MagicMock, call +from unittest.mock import patch, MagicMock from signalwire.prefabs.survey import SurveyAgent @@ -21,6 +20,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_survey( survey_name: str = "Test Survey", questions: list[dict[str, Any]] | None = None, @@ -48,11 +48,12 @@ def _make_survey( } ] - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ) as mock_init: + with ( + patch( + "signalwire.prefabs.survey.AgentBase.__init__", return_value=None + ) as mock_init, # Mock the methods that _setup_survey_agent calls on 'self' - with patch.multiple( + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -60,19 +61,20 @@ def _make_survey( set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent( - survey_name=survey_name, - questions=questions, - introduction=introduction, - conclusion=conclusion, - brand_name=brand_name, - max_retries=max_retries, - name=name, - route=route, - ) + survey = SurveyAgent( + survey_name=survey_name, + questions=questions, + introduction=introduction, + conclusion=conclusion, + brand_name=brand_name, + max_retries=max_retries, + name=name, + route=route, + ) return survey, mock_init @@ -134,9 +136,15 @@ class TestSurveyInitialization: def test_basic_initialization(self) -> None: """SurveyAgent stores survey_name, questions, and defaults correctly.""" questions = [ - {"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5, "required": True} + { + "id": "q1", + "text": "Rate us?", + "type": "rating", + "scale": 5, + "required": True, + } ] - survey, mock_init = _make_survey( + survey, _mock_init = _make_survey( survey_name="My Survey", questions=questions, brand_name="Acme", @@ -150,7 +158,7 @@ def test_basic_initialization(self) -> None: def test_super_init_called_with_correct_args(self) -> None: """AgentBase.__init__ is invoked with the expected keyword arguments.""" - survey, mock_init = _make_survey( + _survey, mock_init = _make_survey( name="custom_name", route="/custom", ) @@ -198,9 +206,7 @@ def test_max_retries_setting(self) -> None: def test_kwargs_forwarded_to_super(self) -> None: """Extra keyword arguments are forwarded to AgentBase.__init__.""" - questions = [ - {"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5} - ] + questions = [{"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5}] with patch( "signalwire.prefabs.survey.AgentBase.__init__", return_value=None ) as mock_init: @@ -351,14 +357,11 @@ class TestSetupSurveyAgent: def test_prompt_sections_added(self) -> None: """_setup_survey_agent adds expected prompt sections.""" - questions = [ - {"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5} - ] + questions = [{"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5}] - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -366,33 +369,31 @@ def test_prompt_sections_added(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent(survey_name="Test", questions=questions) + survey = SurveyAgent(survey_name="Test", questions=questions) - # Collect section titles from prompt_add_section calls - section_titles = [ - c.args[0] if c.args else c.kwargs.get("title") - for c in survey.prompt_add_section.call_args_list # type: ignore[attr-defined] # mock attr - ] - assert "Personality" in section_titles - assert "Goal" in section_titles - assert "Instructions" in section_titles - assert "Introduction" in section_titles - assert "Survey Questions" in section_titles - assert "Conclusion" in section_titles + # Collect section titles from prompt_add_section calls + section_titles = [ + c.args[0] if c.args else c.kwargs.get("title") + for c in survey.prompt_add_section.call_args_list # type: ignore[attr-defined] # mock attr + ] + assert "Personality" in section_titles + assert "Goal" in section_titles + assert "Instructions" in section_titles + assert "Introduction" in section_titles + assert "Survey Questions" in section_titles + assert "Conclusion" in section_titles def test_post_prompt_set(self) -> None: """_setup_survey_agent calls set_post_prompt with a JSON template.""" - questions = [ - {"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5} - ] + questions = [{"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5}] - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -400,15 +401,16 @@ def test_post_prompt_set(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent(survey_name="Test", questions=questions) - survey.set_post_prompt.assert_called_once() # type: ignore[attr-defined] # mock attr - post_prompt_arg = survey.set_post_prompt.call_args[0][0] # type: ignore[attr-defined] # mock attr - assert "survey_name" in post_prompt_arg - assert "responses" in post_prompt_arg - assert "completion_status" in post_prompt_arg + survey = SurveyAgent(survey_name="Test", questions=questions) + survey.set_post_prompt.assert_called_once() # type: ignore[attr-defined] # mock attr + post_prompt_arg = survey.set_post_prompt.call_args[0][0] # type: ignore[attr-defined] # mock attr + assert "survey_name" in post_prompt_arg + assert "responses" in post_prompt_arg + assert "completion_status" in post_prompt_arg def test_hints_include_survey_and_brand(self) -> None: """add_hints includes the survey name, brand, and type-specific terms.""" @@ -423,10 +425,9 @@ def test_hints_include_survey_and_brand(self) -> None: }, ] - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -434,40 +435,38 @@ def test_hints_include_survey_and_brand(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent( - survey_name="CX Survey", - questions=questions, - brand_name="Acme", - ) + survey = SurveyAgent( + survey_name="CX Survey", + questions=questions, + brand_name="Acme", + ) - survey.add_hints.assert_called_once() # type: ignore[attr-defined] # mock attr - hints = survey.add_hints.call_args[0][0] # type: ignore[attr-defined] # mock attr - assert "CX Survey" in hints - assert "Acme" in hints - # Rating scale 1..3 - assert "1" in hints - assert "2" in hints - assert "3" in hints - # yes_no - assert "yes" in hints - assert "no" in hints - # multiple_choice options - assert "Alpha" in hints - assert "Beta" in hints + survey.add_hints.assert_called_once() # type: ignore[attr-defined] # mock attr + hints = survey.add_hints.call_args[0][0] # type: ignore[attr-defined] # mock attr + assert "CX Survey" in hints + assert "Acme" in hints + # Rating scale 1..3 + assert "1" in hints + assert "2" in hints + assert "3" in hints + # yes_no + assert "yes" in hints + assert "no" in hints + # multiple_choice options + assert "Alpha" in hints + assert "Beta" in hints def test_params_set(self) -> None: """set_params is called with expected AI parameters.""" - questions = [ - {"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5} - ] + questions = [{"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5}] - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -475,30 +474,28 @@ def test_params_set(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent(survey_name="Test", questions=questions) + survey = SurveyAgent(survey_name="Test", questions=questions) - survey.set_params.assert_called_once() # type: ignore[attr-defined] # mock attr - params = survey.set_params.call_args[0][0] # type: ignore[attr-defined] # mock attr - assert params["wait_for_user"] is False - assert params["end_of_speech_timeout"] == 1500 - assert params["ai_volume"] == 5 - assert params["static_greeting_no_barge"] is True - # static_greeting equals the introduction message - assert params["static_greeting"] == survey.introduction + survey.set_params.assert_called_once() # type: ignore[attr-defined] # mock attr + params = survey.set_params.call_args[0][0] # type: ignore[attr-defined] # mock attr + assert params["wait_for_user"] is False + assert params["end_of_speech_timeout"] == 1500 + assert params["ai_volume"] == 5 + assert params["static_greeting_no_barge"] is True + # static_greeting equals the introduction message + assert params["static_greeting"] == survey.introduction def test_global_data_set(self) -> None: """set_global_data is called with survey metadata.""" - questions = [ - {"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5} - ] + questions = [{"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5}] - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -506,33 +503,31 @@ def test_global_data_set(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent( - survey_name="GD Survey", - questions=questions, - brand_name="GD Co", - max_retries=3, - ) + survey = SurveyAgent( + survey_name="GD Survey", + questions=questions, + brand_name="GD Co", + max_retries=3, + ) - survey.set_global_data.assert_called_once() # type: ignore[attr-defined] # mock attr - gd = survey.set_global_data.call_args[0][0] # type: ignore[attr-defined] # mock attr - assert gd["survey_name"] == "GD Survey" - assert gd["brand_name"] == "GD Co" - assert gd["max_retries"] == 3 - assert gd["questions"] is survey.questions + survey.set_global_data.assert_called_once() # type: ignore[attr-defined] # mock attr + gd = survey.set_global_data.call_args[0][0] # type: ignore[attr-defined] # mock attr + assert gd["survey_name"] == "GD Survey" + assert gd["brand_name"] == "GD Co" + assert gd["max_retries"] == 3 + assert gd["questions"] is survey.questions def test_native_functions_set(self) -> None: """set_native_functions is called with check_time.""" - questions = [ - {"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5} - ] + questions = [{"id": "q1", "text": "Rate us?", "type": "rating", "scale": 5}] - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -540,11 +535,12 @@ def test_native_functions_set(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent(survey_name="Test", questions=questions) - survey.set_native_functions.assert_called_once_with(["check_time"]) # type: ignore[attr-defined] # mock attr + survey = SurveyAgent(survey_name="Test", questions=questions) + survey.set_native_functions.assert_called_once_with(["check_time"]) # type: ignore[attr-defined] # mock attr class TestValidateResponse: @@ -553,49 +549,37 @@ class TestValidateResponse: def test_valid_rating_response(self) -> None: """A numeric rating within range is valid.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q1", "response": "3"}, {} - ) + result = survey.validate_response({"question_id": "q1", "response": "3"}, {}) assert "valid" in result.response.lower() def test_valid_rating_boundary_low(self) -> None: """Rating of 1 (lower boundary) is valid.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q1", "response": "1"}, {} - ) + result = survey.validate_response({"question_id": "q1", "response": "1"}, {}) assert "valid" in result.response.lower() def test_valid_rating_boundary_high(self) -> None: """Rating at the upper boundary is valid.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q1", "response": "5"}, {} - ) + result = survey.validate_response({"question_id": "q1", "response": "5"}, {}) assert "valid" in result.response.lower() def test_invalid_rating_too_high(self) -> None: """Rating above scale is invalid.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q1", "response": "6"}, {} - ) + result = survey.validate_response({"question_id": "q1", "response": "6"}, {}) assert "invalid" in result.response.lower() def test_invalid_rating_too_low(self) -> None: """Rating of 0 is invalid.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q1", "response": "0"}, {} - ) + result = survey.validate_response({"question_id": "q1", "response": "0"}, {}) assert "invalid" in result.response.lower() def test_invalid_rating_negative(self) -> None: """Negative rating is invalid.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q1", "response": "-1"}, {} - ) + result = survey.validate_response({"question_id": "q1", "response": "-1"}, {}) assert "invalid" in result.response.lower() def test_invalid_rating_non_numeric(self) -> None: @@ -609,33 +593,25 @@ def test_invalid_rating_non_numeric(self) -> None: def test_valid_yes_no_yes(self) -> None: """'yes' is valid for a yes_no question.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q2", "response": "yes"}, {} - ) + result = survey.validate_response({"question_id": "q2", "response": "yes"}, {}) assert "valid" in result.response.lower() def test_valid_yes_no_no(self) -> None: """'no' is valid for a yes_no question.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q2", "response": "no"}, {} - ) + result = survey.validate_response({"question_id": "q2", "response": "no"}, {}) assert "valid" in result.response.lower() def test_valid_yes_no_y(self) -> None: """'y' is valid for a yes_no question.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q2", "response": "y"}, {} - ) + result = survey.validate_response({"question_id": "q2", "response": "y"}, {}) assert "valid" in result.response.lower() def test_valid_yes_no_n(self) -> None: """'n' is valid for a yes_no question.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q2", "response": "n"}, {} - ) + result = survey.validate_response({"question_id": "q2", "response": "n"}, {}) assert "valid" in result.response.lower() def test_invalid_yes_no(self) -> None: @@ -673,18 +649,18 @@ def test_invalid_multiple_choice(self) -> None: def test_valid_open_ended_non_required(self) -> None: """An empty answer to a non-required open-ended question is valid.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q4", "response": ""}, {} - ) + result = survey.validate_response({"question_id": "q4", "response": ""}, {}) # q4 is not required, so empty is fine - assert "valid" in result.response.lower() or "recorded" in result.response.lower() or result.response != "" + assert ( + "valid" in result.response.lower() + or "recorded" in result.response.lower() + or result.response != "" + ) def test_invalid_open_ended_required_empty(self) -> None: """An empty answer to a required open-ended question is invalid.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q5", "response": ""}, {} - ) + result = survey.validate_response({"question_id": "q5", "response": ""}, {}) assert "required" in result.response.lower() def test_valid_open_ended_required(self) -> None: @@ -701,13 +677,17 @@ def test_unknown_question_id(self) -> None: result = survey.validate_response( {"question_id": "nonexistent", "response": "anything"}, {} ) - assert "not found" in result.response.lower() or "error" in result.response.lower() + assert ( + "not found" in result.response.lower() or "error" in result.response.lower() + ) def test_missing_question_id(self) -> None: """Missing question_id in args returns an error.""" survey = _bare_survey() result = survey.validate_response({"response": "3"}, {}) - assert "not found" in result.response.lower() or "error" in result.response.lower() + assert ( + "not found" in result.response.lower() or "error" in result.response.lower() + ) def test_missing_response_field(self) -> None: """Missing response in args defaults to empty string and validates accordingly.""" @@ -721,9 +701,7 @@ def test_returns_swaig_function_result(self) -> None: from signalwire.core.function_result import FunctionResult survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q1", "response": "3"}, {} - ) + result = survey.validate_response({"question_id": "q1", "response": "3"}, {}) assert isinstance(result, FunctionResult) def test_rating_whitespace_trimmed(self) -> None: @@ -749,9 +727,7 @@ class TestLogResponse: def test_log_known_question(self) -> None: """log_response acknowledges a response for a known question.""" survey = _bare_survey() - result = survey.log_response( - {"question_id": "q1", "response": "5"}, {} - ) + result = survey.log_response({"question_id": "q1", "response": "5"}, {}) assert "recorded" in result.response.lower() assert "How satisfied" in result.response @@ -769,17 +745,13 @@ def test_returns_swaig_function_result(self) -> None: from signalwire.core.function_result import FunctionResult survey = _bare_survey() - result = survey.log_response( - {"question_id": "q1", "response": "3"}, {} - ) + result = survey.log_response({"question_id": "q1", "response": "3"}, {}) assert isinstance(result, FunctionResult) def test_log_response_includes_question_text(self) -> None: """The acknowledgement mentions the question text.""" survey = _bare_survey() - result = survey.log_response( - {"question_id": "q2", "response": "yes"}, {} - ) + result = survey.log_response({"question_id": "q2", "response": "yes"}, {}) assert "Would you recommend us?" in result.response def test_missing_question_id(self) -> None: @@ -826,7 +798,9 @@ def test_on_summary_with_none(self, capsys: pytest.CaptureFixture[str]) -> None: captured = capsys.readouterr() assert captured.out == "" - def test_on_summary_with_empty_dict(self, capsys: pytest.CaptureFixture[str]) -> None: + def test_on_summary_with_empty_dict( + self, capsys: pytest.CaptureFixture[str] + ) -> None: """on_summary with an empty dict produces no output (empty dict is falsy).""" survey = _bare_survey() # In Python, {} is falsy, so the `if summary:` guard skips processing. @@ -834,7 +808,9 @@ def test_on_summary_with_empty_dict(self, capsys: pytest.CaptureFixture[str]) -> captured = capsys.readouterr() assert captured.out == "" - def test_on_summary_error_handling(self, capsys: pytest.CaptureFixture[str]) -> None: + def test_on_summary_error_handling( + self, capsys: pytest.CaptureFixture[str] + ) -> None: """on_summary catches exceptions and prints error.""" survey = _bare_survey() # Passing a dict-like that raises on json.dumps via a bad key @@ -860,7 +836,12 @@ def test_all_question_types_together(self) -> None: """A survey with all four question types initializes without error.""" questions: list[dict[str, Any]] = [ {"id": "r1", "text": "Rate 1-5?", "type": "rating", "scale": 5}, - {"id": "mc1", "text": "Pick one?", "type": "multiple_choice", "options": ["A", "B"]}, + { + "id": "mc1", + "text": "Pick one?", + "type": "multiple_choice", + "options": ["A", "B"], + }, {"id": "yn1", "text": "Yes or no?", "type": "yes_no"}, {"id": "oe1", "text": "Comments?", "type": "open_ended"}, ] @@ -878,9 +859,7 @@ def test_many_questions(self) -> None: def test_rating_custom_scale(self) -> None: """A rating question with a custom scale stores it properly.""" - questions = [ - {"id": "q1", "text": "Rate 1-10?", "type": "rating", "scale": 10} - ] + questions = [{"id": "q1", "text": "Rate 1-10?", "type": "rating", "scale": 10}] survey, _ = _make_survey(questions=questions) assert survey.questions[0]["scale"] == 10 @@ -891,31 +870,27 @@ class TestValidateResponseEdgeCases: def test_rating_with_float_string(self) -> None: """A float string like '3.5' is invalid for a rating question.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q1", "response": "3.5"}, {} - ) + result = survey.validate_response({"question_id": "q1", "response": "3.5"}, {}) assert "invalid" in result.response.lower() def test_empty_args(self) -> None: """Completely empty args dict falls through to 'not found'.""" survey = _bare_survey() result = survey.validate_response({}, {}) - assert "not found" in result.response.lower() or "error" in result.response.lower() + assert ( + "not found" in result.response.lower() or "error" in result.response.lower() + ) def test_open_ended_whitespace_only_required(self) -> None: """Whitespace-only answer to a required open-ended question is invalid.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q5", "response": " "}, {} - ) + result = survey.validate_response({"question_id": "q5", "response": " "}, {}) assert "required" in result.response.lower() def test_yes_no_uppercase(self) -> None: """'YES' in uppercase is valid for a yes_no question.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q2", "response": "YES"}, {} - ) + result = survey.validate_response({"question_id": "q2", "response": "YES"}, {}) assert "valid" in result.response.lower() def test_yes_no_with_whitespace(self) -> None: @@ -929,9 +904,7 @@ def test_yes_no_with_whitespace(self) -> None: def test_multiple_choice_partial_match_invalid(self) -> None: """A partial match like 'Spee' (not exact) is invalid for multiple_choice.""" survey = _bare_survey() - result = survey.validate_response( - {"question_id": "q3", "response": "Spee"}, {} - ) + result = survey.validate_response({"question_id": "q3", "response": "Spee"}, {}) assert "invalid" in result.response.lower() @@ -970,10 +943,9 @@ class TestSurveySetupDetails: def test_personality_section_includes_brand(self) -> None: """The Personality prompt section includes the brand name.""" - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -981,30 +953,30 @@ def test_personality_section_includes_brand(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent( - survey_name="Test", - questions=[{"id": "q1", "text": "Q?", "type": "open_ended"}], - brand_name="MyCorp", - ) + survey = SurveyAgent( + survey_name="Test", + questions=[{"id": "q1", "text": "Q?", "type": "open_ended"}], + brand_name="MyCorp", + ) - # Find the Personality call - for c in survey.prompt_add_section.call_args_list: # type: ignore[attr-defined] # mock attr - if c.args and c.args[0] == "Personality": - body = c.kwargs.get("body", "") - assert "MyCorp" in body - break - else: - pytest.fail("Personality section not found") + # Find the Personality call + for c in survey.prompt_add_section.call_args_list: # type: ignore[attr-defined] # mock attr + if c.args and c.args[0] == "Personality": + body = c.kwargs.get("body", "") + assert "MyCorp" in body + break + else: + pytest.fail("Personality section not found") def test_goal_section_includes_survey_name(self) -> None: """The Goal prompt section includes the survey name.""" - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -1012,28 +984,28 @@ def test_goal_section_includes_survey_name(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent( - survey_name="Satisfaction Survey", - questions=[{"id": "q1", "text": "Q?", "type": "open_ended"}], - ) + survey = SurveyAgent( + survey_name="Satisfaction Survey", + questions=[{"id": "q1", "text": "Q?", "type": "open_ended"}], + ) - for c in survey.prompt_add_section.call_args_list: # type: ignore[attr-defined] # mock attr - if c.args and c.args[0] == "Goal": - body = c.kwargs.get("body", "") - assert "Satisfaction Survey" in body - break - else: - pytest.fail("Goal section not found") + for c in survey.prompt_add_section.call_args_list: # type: ignore[attr-defined] # mock attr + if c.args and c.args[0] == "Goal": + body = c.kwargs.get("body", "") + assert "Satisfaction Survey" in body + break + else: + pytest.fail("Goal section not found") def test_instructions_section_has_bullets(self) -> None: """The Instructions prompt section contains bullet points.""" - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -1041,37 +1013,42 @@ def test_instructions_section_has_bullets(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent( - survey_name="T", - questions=[{"id": "q1", "text": "Q?", "type": "open_ended"}], - max_retries=3, - ) + survey = SurveyAgent( + survey_name="T", + questions=[{"id": "q1", "text": "Q?", "type": "open_ended"}], + max_retries=3, + ) - for c in survey.prompt_add_section.call_args_list: # type: ignore[attr-defined] # mock attr - if c.args and c.args[0] == "Instructions": - bullets = c.kwargs.get("bullets", []) - assert isinstance(bullets, list) - assert len(bullets) > 0 - # Check that max_retries appears somewhere in the bullets - assert any("3" in b for b in bullets) - break - else: - pytest.fail("Instructions section not found") + for c in survey.prompt_add_section.call_args_list: # type: ignore[attr-defined] # mock attr + if c.args and c.args[0] == "Instructions": + bullets = c.kwargs.get("bullets", []) + assert isinstance(bullets, list) + assert len(bullets) > 0 + # Check that max_retries appears somewhere in the bullets + assert any("3" in b for b in bullets) + break + else: + pytest.fail("Instructions section not found") def test_survey_questions_section_has_subsections(self) -> None: """The Survey Questions prompt section has subsections for each question.""" questions: list[dict[str, Any]] = [ {"id": "q1", "text": "Rate?", "type": "rating", "scale": 5}, - {"id": "q2", "text": "Pick?", "type": "multiple_choice", "options": ["A", "B"]}, + { + "id": "q2", + "text": "Pick?", + "type": "multiple_choice", + "options": ["A", "B"], + }, ] - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -1079,31 +1056,31 @@ def test_survey_questions_section_has_subsections(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent(survey_name="T", questions=questions) - - for c in survey.prompt_add_section.call_args_list: # type: ignore[attr-defined] # mock attr - if c.args and c.args[0] == "Survey Questions": - subsections = c.kwargs.get("subsections", []) - assert len(subsections) == 2 - # First subsection title is the question text - assert subsections[0]["title"] == "Rate?" - assert "Scale: 1-5" in subsections[0]["body"] - # Second subsection - assert subsections[1]["title"] == "Pick?" - assert "A, B" in subsections[1]["body"] - break - else: - pytest.fail("Survey Questions section not found") + survey = SurveyAgent(survey_name="T", questions=questions) + + for c in survey.prompt_add_section.call_args_list: # type: ignore[attr-defined] # mock attr + if c.args and c.args[0] == "Survey Questions": + subsections = c.kwargs.get("subsections", []) + assert len(subsections) == 2 + # First subsection title is the question text + assert subsections[0]["title"] == "Rate?" + assert "Scale: 1-5" in subsections[0]["body"] + # Second subsection + assert subsections[1]["title"] == "Pick?" + assert "A, B" in subsections[1]["body"] + break + else: + pytest.fail("Survey Questions section not found") def test_introduction_section_body(self) -> None: """The Introduction section body includes the introduction message.""" - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -1111,29 +1088,29 @@ def test_introduction_section_body(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent( - survey_name="T", - questions=[{"id": "q1", "text": "Q?", "type": "open_ended"}], - introduction="Hello and welcome!", - ) + survey = SurveyAgent( + survey_name="T", + questions=[{"id": "q1", "text": "Q?", "type": "open_ended"}], + introduction="Hello and welcome!", + ) - for c in survey.prompt_add_section.call_args_list: # type: ignore[attr-defined] # mock attr - if c.args and c.args[0] == "Introduction": - body = c.kwargs.get("body", "") - assert "Hello and welcome!" in body - break - else: - pytest.fail("Introduction section not found") + for c in survey.prompt_add_section.call_args_list: # type: ignore[attr-defined] # mock attr + if c.args and c.args[0] == "Introduction": + body = c.kwargs.get("body", "") + assert "Hello and welcome!" in body + break + else: + pytest.fail("Introduction section not found") def test_conclusion_section_body(self) -> None: """The Conclusion section body includes the conclusion message.""" - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -1141,22 +1118,23 @@ def test_conclusion_section_body(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent( - survey_name="T", - questions=[{"id": "q1", "text": "Q?", "type": "open_ended"}], - conclusion="Thanks for your time!", - ) + survey = SurveyAgent( + survey_name="T", + questions=[{"id": "q1", "text": "Q?", "type": "open_ended"}], + conclusion="Thanks for your time!", + ) - for c in survey.prompt_add_section.call_args_list: # type: ignore[attr-defined] # mock attr - if c.args and c.args[0] == "Conclusion": - body = c.kwargs.get("body", "") - assert "Thanks for your time!" in body - break - else: - pytest.fail("Conclusion section not found") + for c in survey.prompt_add_section.call_args_list: # type: ignore[attr-defined] # mock attr + if c.args and c.args[0] == "Conclusion": + body = c.kwargs.get("body", "") + assert "Thanks for your time!" in body + break + else: + pytest.fail("Conclusion section not found") class TestSurveyHintsEdgeCases: @@ -1165,10 +1143,9 @@ class TestSurveyHintsEdgeCases: def test_hints_no_type_specific_terms(self) -> None: """An open_ended-only survey has no type-specific hint terms (just name/brand).""" questions = [{"id": "q1", "text": "Comments?", "type": "open_ended"}] - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -1176,25 +1153,25 @@ def test_hints_no_type_specific_terms(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent( - survey_name="Open Survey", - questions=questions, - brand_name="Brand", - ) - hints = survey.add_hints.call_args[0][0] # type: ignore[attr-defined] # mock attr - # Only survey_name and brand_name - assert hints == ["Open Survey", "Brand"] + survey = SurveyAgent( + survey_name="Open Survey", + questions=questions, + brand_name="Brand", + ) + hints = survey.add_hints.call_args[0][0] # type: ignore[attr-defined] # mock attr + # Only survey_name and brand_name + assert hints == ["Open Survey", "Brand"] def test_hints_large_rating_scale(self) -> None: """A rating question with scale=10 generates hints 1-10.""" questions = [{"id": "q1", "text": "Rate?", "type": "rating", "scale": 10}] - with patch( - "signalwire.prefabs.survey.AgentBase.__init__", return_value=None - ): - with patch.multiple( + with ( + patch("signalwire.prefabs.survey.AgentBase.__init__", return_value=None), + patch.multiple( "signalwire.prefabs.survey.AgentBase", prompt_add_section=MagicMock(), set_post_prompt=MagicMock(), @@ -1202,15 +1179,16 @@ def test_hints_large_rating_scale(self) -> None: set_params=MagicMock(), set_global_data=MagicMock(), set_native_functions=MagicMock(), - ): - from signalwire.prefabs.survey import SurveyAgent + ), + ): + from signalwire.prefabs.survey import SurveyAgent - survey = SurveyAgent( - survey_name="S", - questions=questions, - brand_name="B", - ) - hints = survey.add_hints.call_args[0][0] # type: ignore[attr-defined] # mock attr - # Should have S, B, then "1".."10" - for i in range(1, 11): - assert str(i) in hints + survey = SurveyAgent( + survey_name="S", + questions=questions, + brand_name="B", + ) + hints = survey.add_hints.call_args[0][0] # type: ignore[attr-defined] # mock attr + # Should have S, B, then "1".."10" + for i in range(1, 11): + assert str(i) in hints diff --git a/tests/unit/relay/conftest.py b/tests/unit/relay/conftest.py index 4d269c78..a1c3e5d4 100644 --- a/tests/unit/relay/conftest.py +++ b/tests/unit/relay/conftest.py @@ -50,8 +50,8 @@ async def test_dial_round_trip(signalwire_relay_client, mock_relay): import asyncio import atexit import json +import logging import os -import socket import subprocess import sys import threading @@ -59,7 +59,8 @@ async def test_dial_round_trip(signalwire_relay_client, mock_relay): import uuid from dataclasses import dataclass from pathlib import Path -from typing import Any, AsyncIterator, Callable, Iterable, Iterator, Optional, cast +from typing import Any, cast +from collections.abc import AsyncIterator, Callable, Iterable, Iterator from unittest.mock import AsyncMock, patch from urllib.parse import quote @@ -83,7 +84,7 @@ async def test_dial_round_trip(signalwire_relay_client, mock_relay): # --------------------------------------------------------------------------- -def _discover_mock_package(name: str) -> Optional[str]: +def _discover_mock_package(name: str) -> str | None: """Return the ``../porting-sdk/test_harness//`` package root (prepended to ``sys.path``), or ``None`` when no adjacent ``porting-sdk`` is reachable.""" here = Path(__file__).resolve() @@ -140,8 +141,8 @@ def __aiter__(self) -> MockWebSocket: async def __anext__(self) -> str: try: return await self.recv() - except (websockets.exceptions.ConnectionClosed, StopAsyncIteration): - raise StopAsyncIteration + except (websockets.exceptions.ConnectionClosed, StopAsyncIteration) as err: + raise StopAsyncIteration from err # --- test helpers --- @@ -341,9 +342,10 @@ def _factory( # Per-process default for parallel RelayClient connections during tests. The -# SDK's _MAX_CONNECTIONS guard otherwise refuses a 2nd client in the same -# process; bumping it lets a single test create multiple clients (used by the -# reconnect-with-protocol-string tests). +# SDK reads RELAY_MAX_CONNECTIONS at connect time and defaults to 1, which +# would refuse a 2nd client in the same process; raising the ambient value lets +# a single test create multiple clients (used by the reconnect-with-protocol- +# string tests). Tests that need a specific limit override it with monkeypatch. os.environ.setdefault("RELAY_MAX_CONNECTIONS", "16") _DEFAULT_WS_PORT = 8773 @@ -376,30 +378,53 @@ def _resolve_http_port(ws_port: int) -> int: return ws_port + 1000 -def _probe_health(http_url: str) -> bool: +def _ws_port_accepting(ws_port: int) -> bool: + """Whether something is actually listening on the WS port we will connect to.""" + import socket + + try: + with socket.create_connection(("127.0.0.1", ws_port), timeout=_PROBE_TIMEOUT_S): + return True + except OSError: + return False + + +def _probe_health(http_url: str, ws_port: int | None = None) -> bool: + """Whether a usable mock_relay is already up. + + Checks BOTH transports. The HTTP health endpoint and the WS endpoint are two + DIFFERENT ports (HTTP defaults to WS+1000), so a green HTTP probe alone does + not mean the socket the tests connect over is alive: a foreign process — a + concurrent lane's mock, a leftover from an earlier run — can own the HTTP + port while nothing serves WS, and then the caller skips spawning its own + server and every test dies with ConnectionRefusedError on the WS port. That + failure was observed live on port 8773 during a parallel docs run. + """ import requests try: resp = requests.get(f"{http_url}/__mock__/health", timeout=_PROBE_TIMEOUT_S) if resp.status_code != 200: return False - return "schemas_loaded" in resp.json() + if "schemas_loaded" not in resp.json(): + return False except Exception: return False + return ws_port is None or _ws_port_accepting(ws_port) class _SharedRelayServer: """Process-wide handle to the one shared mock_relay server (WS + HTTP).""" def __init__(self) -> None: - self.http_url: Optional[str] = None - self.ws_url: Optional[str] = None - self.relay_host: Optional[str] = None - self._child: Optional[subprocess.Popen[bytes]] = None + self.http_url: str | None = None + self.ws_url: str | None = None + self.relay_host: str | None = None + self._child: subprocess.Popen[bytes] | None = None self._lock = threading.Lock() - self._error: Optional[str] = None + self._error: str | None = None - def ensure(self) -> "_SharedRelayServer": + def ensure(self) -> _SharedRelayServer: with self._lock: if self.http_url is not None: return self @@ -412,7 +437,7 @@ def ensure(self) -> "_SharedRelayServer": ws_url = f"ws://127.0.0.1:{ws_port}" relay_host = f"127.0.0.1:{ws_port}" - if _probe_health(http_url): + if _probe_health(http_url, ws_port): self.http_url, self.ws_url, self.relay_host = ( http_url, ws_url, @@ -454,7 +479,7 @@ def ensure(self) -> "_SharedRelayServer": deadline = time.time() + _STARTUP_TIMEOUT_S while time.time() < deadline: - if _probe_health(http_url): + if _probe_health(http_url, ws_port): self.http_url, self.ws_url, self.relay_host = ( http_url, ws_url, @@ -501,7 +526,7 @@ class _RelayJournalEntry: session_id: str @classmethod - def from_dict(cls, d: dict[str, Any]) -> "_RelayJournalEntry": + def from_dict(cls, d: dict[str, Any]) -> _RelayJournalEntry: return cls( timestamp=float(d.get("timestamp", 0.0)), direction=str(d.get("direction", "")), @@ -550,7 +575,7 @@ def journal(self) -> list[_RelayJournalEntry]: resp.raise_for_status() return [_RelayJournalEntry.from_dict(d) for d in resp.json()] - def journal_recv(self, *, method: Optional[str] = None) -> list[_RelayJournalEntry]: + def journal_recv(self, *, method: str | None = None) -> list[_RelayJournalEntry]: """Return inbound (SDK→server) journal entries, optionally by method.""" entries = [e for e in self.journal() if e.direction == "recv"] if method is not None: @@ -558,7 +583,7 @@ def journal_recv(self, *, method: Optional[str] = None) -> list[_RelayJournalEnt return entries def journal_send( - self, *, event_type: Optional[str] = None + self, *, event_type: str | None = None ) -> list[_RelayJournalEntry]: """Return server→SDK frames, optionally filtered by inner event_type.""" entries = [e for e in self.journal() if e.direction == "send"] @@ -620,7 +645,7 @@ def push( self, frame: dict[str, Any], *, - session_id: Optional[str] = None, + session_id: str | None = None, ) -> dict[str, Any]: """Push a single ``signalwire.event`` (or other) frame to the SDK. @@ -639,13 +664,13 @@ def push( def inbound_call( self, *, - call_id: Optional[str] = None, + call_id: str | None = None, from_number: str = "+15551234567", to_number: str = "+15559876543", context: str = "default", - auto_states: Optional[list[str]] = None, + auto_states: list[str] | None = None, delay_ms: int = 50, - session_id: Optional[str] = None, + session_id: str | None = None, ) -> dict[str, Any]: """Inject an inbound call announcement. @@ -720,7 +745,7 @@ def mock_relay() -> _MockRelayHarness: ) -def _ws_redirect_to_mock(shared: "_SharedRelayServer") -> Any: +def _ws_redirect_to_mock(shared: _SharedRelayServer) -> Any: """Build the websockets.connect override that points the SDK at the mock. The SDK builds its URI as ``f"wss://{self.host}"`` — wss://, no port. We @@ -773,10 +798,18 @@ async def signalwire_relay_client( mock_relay.session_id = client._session_id yield client finally: + # Best-effort teardown. disconnect() awaits ws.close(), which can + # fail if the mock already tore the socket down or the test itself + # closed it — a teardown error must not mask the test's real + # result. Narrow to transport errors and log, so that an unexpected + # failure (a bug in disconnect()) still propagates instead of being + # silently swallowed. try: await client.disconnect() - except Exception: - pass + except (websockets.exceptions.WebSocketException, OSError) as exc: + logging.getLogger(__name__).debug( + "relay client teardown failed (ignored): %r", exc + ) _active_clients.clear() diff --git a/tests/unit/relay/test_actions.py b/tests/unit/relay/test_actions.py index b379d65a..4baf865c 100644 --- a/tests/unit/relay/test_actions.py +++ b/tests/unit/relay/test_actions.py @@ -47,6 +47,7 @@ # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def mock_client() -> MagicMock: client = MagicMock() @@ -71,6 +72,7 @@ async def call(mock_client: MagicMock) -> Call: # Base Action.__init__ # --------------------------------------------------------------------------- + class TestActionInit: """Direct construction of the base Action class — covers Action.__init__.""" @@ -96,6 +98,7 @@ async def test_action_init_stores_attributes(self, call: Call) -> None: # PlayAction direct construction # --------------------------------------------------------------------------- + class TestPlayActionInit: """PlayAction.__init__ via direct construction (not via Call.play).""" @@ -114,6 +117,7 @@ async def test_play_action_init(self, call: Call) -> None: # RecordAction direct construction + pause/resume/stop # --------------------------------------------------------------------------- + class TestRecordActionInit: @pytest.mark.asyncio async def test_record_action_init(self, call: Call) -> None: @@ -124,7 +128,9 @@ async def test_record_action_init(self, call: Call) -> None: assert RECORD_STATE_NO_INPUT in record_action._terminal_states @pytest.mark.asyncio - async def test_record_action_stop_sends_correct_rpc(self, call: Call, mock_client: MagicMock) -> None: + async def test_record_action_stop_sends_correct_rpc( + self, call: Call, mock_client: MagicMock + ) -> None: record_action = RecordAction(call, "rec-2") await record_action.stop() mock_client.execute.assert_called_once_with( @@ -133,7 +139,9 @@ async def test_record_action_stop_sends_correct_rpc(self, call: Call, mock_clien ) @pytest.mark.asyncio - async def test_record_action_pause_sends_correct_rpc(self, call: Call, mock_client: MagicMock) -> None: + async def test_record_action_pause_sends_correct_rpc( + self, call: Call, mock_client: MagicMock + ) -> None: record_action = RecordAction(call, "rec-3") await record_action.pause(behavior="silence") mock_client.execute.assert_called_once_with( @@ -147,7 +155,9 @@ async def test_record_action_pause_sends_correct_rpc(self, call: Call, mock_clie ) @pytest.mark.asyncio - async def test_record_action_pause_no_behavior(self, call: Call, mock_client: MagicMock) -> None: + async def test_record_action_pause_no_behavior( + self, call: Call, mock_client: MagicMock + ) -> None: record_action = RecordAction(call, "rec-4") await record_action.pause() mock_client.execute.assert_called_once_with( @@ -156,7 +166,9 @@ async def test_record_action_pause_no_behavior(self, call: Call, mock_client: Ma ) @pytest.mark.asyncio - async def test_record_action_resume_sends_correct_rpc(self, call: Call, mock_client: MagicMock) -> None: + async def test_record_action_resume_sends_correct_rpc( + self, call: Call, mock_client: MagicMock + ) -> None: record_action = RecordAction(call, "rec-5") await record_action.resume() mock_client.execute.assert_called_once_with( @@ -169,6 +181,7 @@ async def test_record_action_resume_sends_correct_rpc(self, call: Call, mock_cli # DetectAction direct construction + stop # --------------------------------------------------------------------------- + class TestDetectActionInit: @pytest.mark.asyncio async def test_detect_action_init(self, call: Call) -> None: @@ -179,7 +192,9 @@ async def test_detect_action_init(self, call: Call) -> None: assert "error" in detect_action._terminal_states @pytest.mark.asyncio - async def test_detect_action_stop_sends_correct_rpc(self, call: Call, mock_client: MagicMock) -> None: + async def test_detect_action_stop_sends_correct_rpc( + self, call: Call, mock_client: MagicMock + ) -> None: detect_action = DetectAction(call, "det-2") await detect_action.stop() mock_client.execute.assert_called_once_with( @@ -192,6 +207,7 @@ async def test_detect_action_stop_sends_correct_rpc(self, call: Call, mock_clien # CollectAction (play_and_collect) direct construction + stop/volume/start_input_timers # --------------------------------------------------------------------------- + class TestCollectActionInit: @pytest.mark.asyncio async def test_collect_action_init(self, call: Call) -> None: @@ -214,7 +230,9 @@ async def test_collect_action_stop_uses_play_and_collect_method( ) @pytest.mark.asyncio - async def test_collect_action_volume_sends_correct_rpc(self, call: Call, mock_client: MagicMock) -> None: + async def test_collect_action_volume_sends_correct_rpc( + self, call: Call, mock_client: MagicMock + ) -> None: collect_action = CollectAction(call, "col-3") await collect_action.volume(7.5) mock_client.execute.assert_called_once_with( @@ -228,7 +246,9 @@ async def test_collect_action_volume_sends_correct_rpc(self, call: Call, mock_cl ) @pytest.mark.asyncio - async def test_collect_action_start_input_timers(self, call: Call, mock_client: MagicMock) -> None: + async def test_collect_action_start_input_timers( + self, call: Call, mock_client: MagicMock + ) -> None: collect_action = CollectAction(call, "col-4") await collect_action.start_input_timers() mock_client.execute.assert_called_once_with( @@ -241,6 +261,7 @@ async def test_collect_action_start_input_timers(self, call: Call, mock_client: # StandaloneCollectAction direct construction + stop/start_input_timers # --------------------------------------------------------------------------- + class TestStandaloneCollectActionInit: @pytest.mark.asyncio async def test_standalone_collect_action_init(self, call: Call) -> None: @@ -277,6 +298,7 @@ async def test_standalone_collect_action_start_input_timers( # FaxAction direct construction # --------------------------------------------------------------------------- + class TestFaxActionInit: @pytest.mark.asyncio async def test_fax_action_init_send_fax_prefix(self, call: Call) -> None: @@ -295,6 +317,7 @@ async def test_fax_action_init_receive_fax_prefix(self, call: Call) -> None: # TapAction direct construction + stop # --------------------------------------------------------------------------- + class TestTapActionInit: @pytest.mark.asyncio async def test_tap_action_init(self, call: Call) -> None: @@ -304,7 +327,9 @@ async def test_tap_action_init(self, call: Call) -> None: assert "finished" in tap_action._terminal_states @pytest.mark.asyncio - async def test_tap_action_stop_sends_correct_rpc(self, call: Call, mock_client: MagicMock) -> None: + async def test_tap_action_stop_sends_correct_rpc( + self, call: Call, mock_client: MagicMock + ) -> None: tap_action = TapAction(call, "tap-2") await tap_action.stop() mock_client.execute.assert_called_once_with( @@ -317,6 +342,7 @@ async def test_tap_action_stop_sends_correct_rpc(self, call: Call, mock_client: # StreamAction direct construction + stop # --------------------------------------------------------------------------- + class TestStreamActionInit: @pytest.mark.asyncio async def test_stream_action_init(self, call: Call) -> None: @@ -326,7 +352,9 @@ async def test_stream_action_init(self, call: Call) -> None: assert "finished" in stream_action._terminal_states @pytest.mark.asyncio - async def test_stream_action_stop_sends_correct_rpc(self, call: Call, mock_client: MagicMock) -> None: + async def test_stream_action_stop_sends_correct_rpc( + self, call: Call, mock_client: MagicMock + ) -> None: stream_action = StreamAction(call, "stream-2") await stream_action.stop() mock_client.execute.assert_called_once_with( @@ -339,6 +367,7 @@ async def test_stream_action_stop_sends_correct_rpc(self, call: Call, mock_clien # PayAction direct construction + stop # --------------------------------------------------------------------------- + class TestPayActionInit: @pytest.mark.asyncio async def test_pay_action_init(self, call: Call) -> None: @@ -349,7 +378,9 @@ async def test_pay_action_init(self, call: Call) -> None: assert "error" in pay_action._terminal_states @pytest.mark.asyncio - async def test_pay_action_stop_sends_correct_rpc(self, call: Call, mock_client: MagicMock) -> None: + async def test_pay_action_stop_sends_correct_rpc( + self, call: Call, mock_client: MagicMock + ) -> None: pay_action = PayAction(call, "pay-2") await pay_action.stop() mock_client.execute.assert_called_once_with( @@ -362,6 +393,7 @@ async def test_pay_action_stop_sends_correct_rpc(self, call: Call, mock_client: # TranscribeAction direct construction + stop # --------------------------------------------------------------------------- + class TestTranscribeActionInit: @pytest.mark.asyncio async def test_transcribe_action_init(self, call: Call) -> None: @@ -371,7 +403,9 @@ async def test_transcribe_action_init(self, call: Call) -> None: assert "finished" in transcribe_action._terminal_states @pytest.mark.asyncio - async def test_transcribe_action_stop_sends_correct_rpc(self, call: Call, mock_client: MagicMock) -> None: + async def test_transcribe_action_stop_sends_correct_rpc( + self, call: Call, mock_client: MagicMock + ) -> None: transcribe_action = TranscribeAction(call, "trn-2") await transcribe_action.stop() mock_client.execute.assert_called_once_with( @@ -384,6 +418,7 @@ async def test_transcribe_action_stop_sends_correct_rpc(self, call: Call, mock_c # AIAction direct construction + stop # --------------------------------------------------------------------------- + class TestAIActionInit: @pytest.mark.asyncio async def test_ai_action_init(self, call: Call) -> None: @@ -395,7 +430,9 @@ async def test_ai_action_init(self, call: Call) -> None: assert "error" in ai_action._terminal_states @pytest.mark.asyncio - async def test_ai_action_stop_sends_correct_rpc(self, call: Call, mock_client: MagicMock) -> None: + async def test_ai_action_stop_sends_correct_rpc( + self, call: Call, mock_client: MagicMock + ) -> None: ai_action = AIAction(call, "ai-2") await ai_action.stop() mock_client.execute.assert_called_once_with( @@ -408,6 +445,7 @@ async def test_ai_action_stop_sends_correct_rpc(self, call: Call, mock_client: M # Call.__repr__ # --------------------------------------------------------------------------- + class TestCallRepr: """Direct __repr__ invocation so the audit picks up Call.__repr__ as covered.""" diff --git a/tests/unit/relay/test_actions_mock.py b/tests/unit/relay/test_actions_mock.py index 33b813f1..b7466663 100644 --- a/tests/unit/relay/test_actions_mock.py +++ b/tests/unit/relay/test_actions_mock.py @@ -81,7 +81,9 @@ async def _handle(call: Call) -> None: # --------------------------------------------------------------------------- -async def test_play_journals_calling_play(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: +async def test_play_journals_calling_play( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: call = await _answered_inbound_call( signalwire_relay_client, mock_relay, "call-play" ) @@ -96,7 +98,9 @@ async def test_play_journals_calling_play(signalwire_relay_client: RelayClient, assert p["play"][0]["type"] == "tts" -async def test_play_resolves_on_finished_event(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: +async def test_play_resolves_on_finished_event( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: call = await _answered_inbound_call( signalwire_relay_client, mock_relay, "call-play-fin" ) @@ -150,7 +154,9 @@ async def test_wait_timeout_does_not_poison_action( assert event.params.get("state") == "finished" -async def test_play_stop_journals_play_stop(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: +async def test_play_stop_journals_play_stop( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: call = await _answered_inbound_call( signalwire_relay_client, mock_relay, "call-play-stop" ) @@ -216,10 +222,10 @@ def on_done(event: RelayEvent) -> None: # --------------------------------------------------------------------------- -async def test_record_journals_calling_record(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: - call = await _answered_inbound_call( - signalwire_relay_client, mock_relay, "call-rec" - ) +async def test_record_journals_calling_record( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: + call = await _answered_inbound_call(signalwire_relay_client, mock_relay, "call-rec") await call.record( audio={"format": "mp3"}, control_id="rec-ctl-1", @@ -244,9 +250,7 @@ async def test_record_resolves_on_finished_event( {"emit": {"state": "finished", "url": "http://r.wav"}, "delay_ms": 5}, ], ) - action = await call.record( - audio={"format": "wav"}, control_id="rec-ctl-fin" - ) + action = await call.record(audio={"format": "wav"}, control_id="rec-ctl-fin") assert isinstance(action, RecordAction) event = await action.wait(timeout=5) assert event.params.get("state") == "finished" @@ -258,9 +262,7 @@ async def test_record_stop_journals_record_stop( call = await _answered_inbound_call( signalwire_relay_client, mock_relay, "call-rec-stop" ) - action = await call.record( - audio={"format": "wav"}, control_id="rec-ctl-stop" - ) + action = await call.record(audio={"format": "wav"}, control_id="rec-ctl-stop") await action.stop() stops = mock_relay.journal_recv(method="calling.record.stop") assert stops and stops[-1].frame["params"]["control_id"] == "rec-ctl-stop" @@ -275,17 +277,13 @@ async def test_detect_resolves_on_first_detect_payload( signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness ) -> None: """Detect resolves on the first ``params.detect`` payload, not on state.""" - call = await _answered_inbound_call( - signalwire_relay_client, mock_relay, "call-det" - ) + call = await _answered_inbound_call(signalwire_relay_client, mock_relay, "call-det") mock_relay.arm_method( "calling.detect", [ # First payload: a real detect result. Should resolve. { - "emit": { - "detect": {"type": "machine", "params": {"event": "MACHINE"}} - }, + "emit": {"detect": {"type": "machine", "params": {"event": "MACHINE"}}}, "delay_ms": 1, }, # Then a finished — but we already resolved on the first. @@ -324,9 +322,7 @@ async def test_detect_stop_journals_detect_stop( async def test_play_and_collect_journals_play_and_collect( signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness ) -> None: - call = await _answered_inbound_call( - signalwire_relay_client, mock_relay, "call-pac" - ) + call = await _answered_inbound_call(signalwire_relay_client, mock_relay, "call-pac") await call.play_and_collect( media=[{"type": "tts", "params": {"text": "Press 1"}}], collect={"digits": {"max": 1}}, @@ -425,12 +421,8 @@ async def test_play_and_collect_stop_journals_pac_stop( async def test_collect_journals_calling_collect( signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness ) -> None: - call = await _answered_inbound_call( - signalwire_relay_client, mock_relay, "call-col" - ) - action = await call.collect( - digits={"max": 4}, control_id="col-ctl" - ) + call = await _answered_inbound_call(signalwire_relay_client, mock_relay, "call-col") + action = await call.collect(digits={"max": 4}, control_id="col-ctl") assert isinstance(action, StandaloneCollectAction) [entry] = mock_relay.journal_recv(method="calling.collect") assert entry.frame["params"]["digits"] == {"max": 4} @@ -443,9 +435,7 @@ async def test_collect_stop_journals_collect_stop( call = await _answered_inbound_call( signalwire_relay_client, mock_relay, "call-col-stop" ) - action = await call.collect( - digits={"max": 4}, control_id="col-stop" - ) + action = await call.collect(digits={"max": 4}, control_id="col-stop") await action.stop() stops = mock_relay.journal_recv(method="calling.collect.stop") assert stops and stops[-1].frame["params"]["control_id"] == "col-stop" @@ -456,10 +446,10 @@ async def test_collect_stop_journals_collect_stop( # --------------------------------------------------------------------------- -async def test_pay_journals_calling_pay(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: - call = await _answered_inbound_call( - signalwire_relay_client, mock_relay, "call-pay" - ) +async def test_pay_journals_calling_pay( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: + call = await _answered_inbound_call(signalwire_relay_client, mock_relay, "call-pay") await call.pay( payment_connector_url="https://pay.example/connect", control_id="pay-ctl", @@ -472,7 +462,9 @@ async def test_pay_journals_calling_pay(signalwire_relay_client: RelayClient, mo assert p["charge_amount"] == "9.99" -async def test_pay_returns_pay_action(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: +async def test_pay_returns_pay_action( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: call = await _answered_inbound_call( signalwire_relay_client, mock_relay, "call-pay-act" ) @@ -484,7 +476,9 @@ async def test_pay_returns_pay_action(signalwire_relay_client: RelayClient, mock assert action.control_id == "pay-act" -async def test_pay_stop_journals_pay_stop(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: +async def test_pay_stop_journals_pay_stop( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: call = await _answered_inbound_call( signalwire_relay_client, mock_relay, "call-pay-stop" ) @@ -535,10 +529,10 @@ async def test_receive_fax_returns_fax_action( # --------------------------------------------------------------------------- -async def test_tap_journals_calling_tap(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: - call = await _answered_inbound_call( - signalwire_relay_client, mock_relay, "call-tap" - ) +async def test_tap_journals_calling_tap( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: + call = await _answered_inbound_call(signalwire_relay_client, mock_relay, "call-tap") await call.tap( tap={"type": "audio", "params": {"direction": "both"}}, device={"type": "rtp", "params": {"addr": "203.0.113.1", "port": 4000}}, @@ -554,7 +548,9 @@ async def test_tap_journals_calling_tap(signalwire_relay_client: RelayClient, mo assert p["control_id"] == "tap-ctl" -async def test_tap_stop_journals_tap_stop(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: +async def test_tap_stop_journals_tap_stop( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: call = await _answered_inbound_call( signalwire_relay_client, mock_relay, "call-tap-stop" ) @@ -598,9 +594,7 @@ async def test_stream_stop_journals_stream_stop( call = await _answered_inbound_call( signalwire_relay_client, mock_relay, "call-strm-stop" ) - action = await call.stream( - url="wss://stream.example/audio", control_id="strm-stop" - ) + action = await call.stream(url="wss://stream.example/audio", control_id="strm-stop") assert isinstance(action, StreamAction) await action.stop() stops = mock_relay.journal_recv(method="calling.stream.stop") @@ -615,9 +609,7 @@ async def test_stream_stop_journals_stream_stop( async def test_transcribe_journals_calling_transcribe( signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness ) -> None: - call = await _answered_inbound_call( - signalwire_relay_client, mock_relay, "call-tr" - ) + call = await _answered_inbound_call(signalwire_relay_client, mock_relay, "call-tr") action = await call.transcribe(control_id="tr-ctl") assert isinstance(action, TranscribeAction) [entry] = mock_relay.journal_recv(method="calling.transcribe") @@ -641,10 +633,10 @@ async def test_transcribe_stop_journals_transcribe_stop( # --------------------------------------------------------------------------- -async def test_ai_journals_calling_ai(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: - call = await _answered_inbound_call( - signalwire_relay_client, mock_relay, "call-ai" - ) +async def test_ai_journals_calling_ai( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: + call = await _answered_inbound_call(signalwire_relay_client, mock_relay, "call-ai") action = await call.ai( prompt={"text": "You are helpful."}, control_id="ai-ctl", @@ -656,13 +648,13 @@ async def test_ai_journals_calling_ai(signalwire_relay_client: RelayClient, mock assert p["control_id"] == "ai-ctl" -async def test_ai_stop_journals_ai_stop(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: +async def test_ai_stop_journals_ai_stop( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: call = await _answered_inbound_call( signalwire_relay_client, mock_relay, "call-ai-stop" ) - action = await call.ai( - prompt={"text": "You are helpful."}, control_id="ai-stop" - ) + action = await call.ai(prompt={"text": "You are helpful."}, control_id="ai-stop") await action.stop() stops = mock_relay.journal_recv(method="calling.ai.stop") assert stops and stops[-1].frame["params"]["control_id"] == "ai-stop" @@ -684,9 +676,7 @@ async def test_concurrent_play_and_record_route_independently( [{"type": "silence", "params": {"duration": 60}}], control_id="ctl-play-x", ) - record_action = await call.record( - audio={"format": "wav"}, control_id="ctl-rec-y" - ) + record_action = await call.record(audio={"format": "wav"}, control_id="ctl-rec-y") assert play_action.control_id == "ctl-play-x" assert record_action.control_id == "ctl-rec-y" diff --git a/tests/unit/relay/test_call.py b/tests/unit/relay/test_call.py index 4f2633ba..b103aab7 100644 --- a/tests/unit/relay/test_call.py +++ b/tests/unit/relay/test_call.py @@ -2,13 +2,11 @@ import asyncio import pytest -import uuid -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from signalwire.relay.call import ( Call, - Action, PlayAction, RecordAction, DetectAction, @@ -35,8 +33,10 @@ # Fixtures # --------------------------------------------------------------------------- + class MockRelayError(Exception): """Mock of RelayError for testing call-gone handling.""" + def __init__(self, code: int, message: str) -> None: self.code = code self.message = message @@ -67,9 +67,12 @@ async def call(mock_client: MagicMock) -> Call: # Call._execute tests # --------------------------------------------------------------------------- + class TestCallExecute: @pytest.mark.asyncio - async def test_execute_sends_correct_method(self, call: Call, mock_client: MagicMock) -> None: + async def test_execute_sends_correct_method( + self, call: Call, mock_client: MagicMock + ) -> None: await call._execute("answer") mock_client.execute.assert_called_once_with( "calling.answer", @@ -77,33 +80,52 @@ async def test_execute_sends_correct_method(self, call: Call, mock_client: Magic ) @pytest.mark.asyncio - async def test_execute_merges_extra_params(self, call: Call, mock_client: MagicMock) -> None: + async def test_execute_merges_extra_params( + self, call: Call, mock_client: MagicMock + ) -> None: await call._execute("play", {"control_id": "ctl1", "play": []}) mock_client.execute.assert_called_once_with( "calling.play", - {"node_id": "node-1", "call_id": "call-1", "control_id": "ctl1", "play": []}, + { + "node_id": "node-1", + "call_id": "call-1", + "control_id": "ctl1", + "play": [], + }, ) @pytest.mark.asyncio - async def test_execute_returns_result(self, call: Call, mock_client: MagicMock) -> None: - mock_client.execute.return_value = {"code": "200", "message": "OK", "url": "http://rec.wav"} + async def test_execute_returns_result( + self, call: Call, mock_client: MagicMock + ) -> None: + mock_client.execute.return_value = { + "code": "200", + "message": "OK", + "url": "http://rec.wav", + } result = await call._execute("record", {"control_id": "ctl1"}) assert result["url"] == "http://rec.wav" @pytest.mark.asyncio - async def test_execute_swallows_404(self, call: Call, mock_client: MagicMock) -> None: + async def test_execute_swallows_404( + self, call: Call, mock_client: MagicMock + ) -> None: mock_client.execute.side_effect = MockRelayError(404, "Call not found") result = await call._execute("play", {"control_id": "ctl1"}) assert result == {} @pytest.mark.asyncio - async def test_execute_swallows_410(self, call: Call, mock_client: MagicMock) -> None: + async def test_execute_swallows_410( + self, call: Call, mock_client: MagicMock + ) -> None: mock_client.execute.side_effect = MockRelayError(410, "Call gone") result = await call._execute("play.stop", {"control_id": "ctl1"}) assert result == {} @pytest.mark.asyncio - async def test_execute_raises_non_gone_relay_errors(self, call: Call, mock_client: MagicMock) -> None: + async def test_execute_raises_non_gone_relay_errors( + self, call: Call, mock_client: MagicMock + ) -> None: """A2 contract (Wave 1): only 404/410 (call gone) are swallowed. Every other server error (e.g. 500) RAISES — the SDK previously swallowed ALL coded errors, hiding real failures the docs promised would surface.""" @@ -113,7 +135,9 @@ async def test_execute_raises_non_gone_relay_errors(self, call: Call, mock_clien assert exc.value.code == 500 @pytest.mark.asyncio - async def test_execute_raises_400_class_relay_errors(self, call: Call, mock_client: MagicMock) -> None: + async def test_execute_raises_400_class_relay_errors( + self, call: Call, mock_client: MagicMock + ) -> None: """A2: a 4xx that is NOT 404/410 (e.g. 400 bad params, 401 auth) raises.""" mock_client.execute.side_effect = MockRelayError(400, "Bad params") with pytest.raises(MockRelayError) as exc: @@ -121,7 +145,9 @@ async def test_execute_raises_400_class_relay_errors(self, call: Call, mock_clie assert exc.value.code == 400 @pytest.mark.asyncio - async def test_execute_raises_non_relay_errors(self, call: Call, mock_client: MagicMock) -> None: + async def test_execute_raises_non_relay_errors( + self, call: Call, mock_client: MagicMock + ) -> None: mock_client.execute.side_effect = ConnectionError("lost") with pytest.raises(ConnectionError): await call._execute("play", {"control_id": "ctl1"}) @@ -131,6 +157,7 @@ async def test_execute_raises_non_relay_errors(self, call: Call, mock_client: Ma # Call lifecycle methods # --------------------------------------------------------------------------- + class TestCallLifecycle: @pytest.mark.asyncio async def test_answer(self, call: Call, mock_client: MagicMock) -> None: @@ -159,6 +186,7 @@ async def test_pass(self, call: Call, mock_client: MagicMock) -> None: # Action-based methods # --------------------------------------------------------------------------- + class TestPlayMethod: @pytest.mark.asyncio async def test_play_returns_play_action(self, call: Call) -> None: @@ -183,7 +211,9 @@ async def test_play_with_options(self, call: Call, mock_client: MagicMock) -> No assert params["loop"] == 2 @pytest.mark.asyncio - async def test_play_tts_builds_tts_media(self, call: Call, mock_client: MagicMock) -> None: + async def test_play_tts_builds_tts_media( + self, call: Call, mock_client: MagicMock + ) -> None: # Restored convenience: caller doesn't hand-build the {type,params} shape. await call.play_tts(text="Welcome!", gender="female") method, params = mock_client.execute.call_args[0] @@ -193,7 +223,9 @@ async def test_play_tts_builds_tts_media(self, call: Call, mock_client: MagicMoc ] @pytest.mark.asyncio - async def test_play_audio_builds_audio_media(self, call: Call, mock_client: MagicMock) -> None: + async def test_play_audio_builds_audio_media( + self, call: Call, mock_client: MagicMock + ) -> None: await call.play_audio(url="https://example.com/a.mp3", volume=2.0) method, params = mock_client.execute.call_args[0] assert method == "calling.play" @@ -203,14 +235,18 @@ async def test_play_audio_builds_audio_media(self, call: Call, mock_client: Magi assert params["volume"] == 2.0 @pytest.mark.asyncio - async def test_play_silence_builds_silence_media(self, call: Call, mock_client: MagicMock) -> None: + async def test_play_silence_builds_silence_media( + self, call: Call, mock_client: MagicMock + ) -> None: await call.play_silence(duration=1.5) method, params = mock_client.execute.call_args[0] assert method == "calling.play" assert params["play"] == [{"type": "silence", "params": {"duration": 1.5}}] @pytest.mark.asyncio - async def test_play_ringtone_builds_ringtone_media(self, call: Call, mock_client: MagicMock) -> None: + async def test_play_ringtone_builds_ringtone_media( + self, call: Call, mock_client: MagicMock + ) -> None: await call.play_ringtone(name="us", duration=3.0) method, params = mock_client.execute.call_args[0] assert method == "calling.play" @@ -219,15 +255,21 @@ async def test_play_ringtone_builds_ringtone_media(self, call: Call, mock_client ] @pytest.mark.asyncio - async def test_detect_digit_builds_detect(self, call: Call, mock_client: MagicMock) -> None: + async def test_detect_digit_builds_detect( + self, call: Call, mock_client: MagicMock + ) -> None: await call.detect_digit(digits="123") method, params = mock_client.execute.call_args[0] assert method == "calling.detect" assert params["detect"] == {"type": "digit", "params": {"digits": "123"}} @pytest.mark.asyncio - async def test_detect_answering_machine_builds_detect(self, call: Call, mock_client: MagicMock) -> None: - await call.detect_answering_machine(end_silence_timeout=2.0, machine_words_threshold=5) + async def test_detect_answering_machine_builds_detect( + self, call: Call, mock_client: MagicMock + ) -> None: + await call.detect_answering_machine( + end_silence_timeout=2.0, machine_words_threshold=5 + ) method, params = mock_client.execute.call_args[0] assert method == "calling.detect" assert params["detect"] == { @@ -236,14 +278,18 @@ async def test_detect_answering_machine_builds_detect(self, call: Call, mock_cli } @pytest.mark.asyncio - async def test_detect_fax_builds_detect(self, call: Call, mock_client: MagicMock) -> None: + async def test_detect_fax_builds_detect( + self, call: Call, mock_client: MagicMock + ) -> None: await call.detect_fax(tone="CED") method, params = mock_client.execute.call_args[0] assert method == "calling.detect" assert params["detect"] == {"type": "fax", "params": {"tone": "CED"}} @pytest.mark.asyncio - async def test_prompt_tts_builds_play_and_collect(self, call: Call, mock_client: MagicMock) -> None: + async def test_prompt_tts_builds_play_and_collect( + self, call: Call, mock_client: MagicMock + ) -> None: await call.prompt_tts("Press 1", {"digits": {"max": 1}}) method, params = mock_client.execute.call_args[0] assert method == "calling.play_and_collect" @@ -251,7 +297,9 @@ async def test_prompt_tts_builds_play_and_collect(self, call: Call, mock_client: assert params["collect"] == {"digits": {"max": 1}} @pytest.mark.asyncio - async def test_prompt_audio_builds_play_and_collect(self, call: Call, mock_client: MagicMock) -> None: + async def test_prompt_audio_builds_play_and_collect( + self, call: Call, mock_client: MagicMock + ) -> None: await call.prompt_audio("https://example.com/menu.mp3", {"speech": {}}) method, params = mock_client.execute.call_args[0] assert method == "calling.play_and_collect" @@ -261,7 +309,9 @@ async def test_prompt_audio_builds_play_and_collect(self, call: Call, mock_clien assert params["collect"] == {"speech": {}} @pytest.mark.asyncio - async def test_wait_for_answered_immediate_when_already_answered(self, call: Call) -> None: + async def test_wait_for_answered_immediate_when_already_answered( + self, call: Call + ) -> None: call.state = "answered" event = await asyncio.wait_for(call.wait_for_answered(), timeout=0.5) assert event.params.get("call_state") == "answered" @@ -280,7 +330,9 @@ async def test_wait_for_ending_immediate_when_ended(self, call: Call) -> None: @pytest.mark.asyncio async def test_play_action_stop(self, call: Call, mock_client: MagicMock) -> None: - action = await call.play([{"type": "tts", "params": {"text": "Hi"}}], control_id="ctl1") + action = await call.play( + [{"type": "tts", "params": {"text": "Hi"}}], control_id="ctl1" + ) mock_client.execute.reset_mock() await action.stop() mock_client.execute.assert_called_once_with( @@ -289,8 +341,12 @@ async def test_play_action_stop(self, call: Call, mock_client: MagicMock) -> Non ) @pytest.mark.asyncio - async def test_play_action_pause_resume_volume(self, call: Call, mock_client: MagicMock) -> None: - action = await call.play([{"type": "tts", "params": {"text": "Hi"}}], control_id="ctl1") + async def test_play_action_pause_resume_volume( + self, call: Call, mock_client: MagicMock + ) -> None: + action = await call.play( + [{"type": "tts", "params": {"text": "Hi"}}], control_id="ctl1" + ) mock_client.execute.reset_mock() await action.pause() @@ -314,11 +370,14 @@ async def test_record_returns_record_action(self, call: Call) -> None: assert isinstance(action, RecordAction) @pytest.mark.asyncio - async def test_record_with_audio_params(self, call: Call, mock_client: MagicMock) -> None: + async def test_record_with_audio_params( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.record( audio={"format": "wav", "stereo": True, "direction": "both"}, control_id="r1", ) + assert isinstance(action, RecordAction) args = mock_client.execute.call_args params = args[0][1] assert params["record"]["audio"]["format"] == "wav" @@ -332,7 +391,9 @@ async def test_detect_returns_detect_action(self, call: Call) -> None: assert isinstance(action, DetectAction) @pytest.mark.asyncio - async def test_detect_with_timeout(self, call: Call, mock_client: MagicMock) -> None: + async def test_detect_with_timeout( + self, call: Call, mock_client: MagicMock + ) -> None: await call.detect({"type": "digit"}, timeout=60.0, control_id="d1") params = mock_client.execute.call_args[0][1] assert params["timeout"] == 60.0 @@ -365,25 +426,41 @@ async def test_standalone_collect(self, call: Call, mock_client: MagicMock) -> N assert mock_client.execute.call_args[0][0] == "calling.collect" @pytest.mark.asyncio - async def test_standalone_collect_stop(self, call: Call, mock_client: MagicMock) -> None: + async def test_standalone_collect_stop( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.collect(digits={"max": 1}, control_id="col1") mock_client.execute.reset_mock() await action.stop() assert mock_client.execute.call_args[0][0] == "calling.collect.stop" @pytest.mark.asyncio - async def test_standalone_collect_start_input_timers(self, call: Call, mock_client: MagicMock) -> None: + async def test_standalone_collect_start_input_timers( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.collect(digits={"max": 1}, control_id="col1") mock_client.execute.reset_mock() await action.start_input_timers() - assert mock_client.execute.call_args[0][0] == "calling.collect.start_input_timers" + assert ( + mock_client.execute.call_args[0][0] == "calling.collect.start_input_timers" + ) class TestConnectDisconnect: @pytest.mark.asyncio async def test_connect(self, call: Call, mock_client: MagicMock) -> None: await call.connect( - [[{"type": "phone", "params": {"to_number": "+15551234567", "from_number": "+15559876543"}}]], + [ + [ + { + "type": "phone", + "params": { + "to_number": "+15551234567", + "from_number": "+15559876543", + }, + } + ] + ], ringback=[{"type": "ringtone", "params": {"name": "us"}}], ) params = mock_client.execute.call_args[0][1] @@ -460,7 +537,9 @@ async def test_receive_fax(self, call: Call, mock_client: MagicMock) -> None: assert mock_client.execute.call_args[0][0] == "calling.receive_fax" @pytest.mark.asyncio - async def test_fax_action_stop_uses_correct_prefix(self, call: Call, mock_client: MagicMock) -> None: + async def test_fax_action_stop_uses_correct_prefix( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.send_fax("https://example.com/doc.pdf", control_id="fax1") mock_client.execute.reset_mock() await action.stop() @@ -562,7 +641,9 @@ async def test_denoise_stop(self, call: Call, mock_client: MagicMock) -> None: class TestTranscribe: @pytest.mark.asyncio async def test_transcribe(self, call: Call, mock_client: MagicMock) -> None: - action = await call.transcribe(control_id="tr1", status_url="https://cb.example.com") + action = await call.transcribe( + control_id="tr1", status_url="https://cb.example.com" + ) assert isinstance(action, TranscribeAction) params = mock_client.execute.call_args[0][1] assert params["status_url"] == "https://cb.example.com" @@ -594,7 +675,9 @@ async def test_bind_digit(self, call: Call, mock_client: MagicMock) -> None: assert params["max_triggers"] == 3 @pytest.mark.asyncio - async def test_clear_digit_bindings(self, call: Call, mock_client: MagicMock) -> None: + async def test_clear_digit_bindings( + self, call: Call, mock_client: MagicMock + ) -> None: await call.clear_digit_bindings(realm="menu") params = mock_client.execute.call_args[0][1] assert params["realm"] == "menu" @@ -638,7 +721,9 @@ async def test_ai_returns_ai_action(self, call: Call) -> None: assert isinstance(action, AIAction) @pytest.mark.asyncio - async def test_ai_with_full_params(self, call: Call, mock_client: MagicMock) -> None: + async def test_ai_with_full_params( + self, call: Call, mock_client: MagicMock + ) -> None: await call.ai( control_id="ai1", agent="agent-uuid", @@ -702,7 +787,9 @@ async def test_user_event(self, call: Call, mock_client: MagicMock) -> None: class TestQueue: @pytest.mark.asyncio async def test_queue_enter(self, call: Call, mock_client: MagicMock) -> None: - await call.queue_enter("support", control_id="q1", status_url="https://example.com") + await call.queue_enter( + "support", control_id="q1", status_url="https://example.com" + ) params = mock_client.execute.call_args[0][1] assert params["queue_name"] == "support" assert params["control_id"] == "q1" @@ -721,9 +808,12 @@ async def test_queue_leave(self, call: Call, mock_client: MagicMock) -> None: # _start_action tests # --------------------------------------------------------------------------- + class TestStartAction: @pytest.mark.asyncio - async def test_ended_call_resolves_gracefully(self, call: Call, mock_client: MagicMock) -> None: + async def test_ended_call_resolves_gracefully( + self, call: Call, mock_client: MagicMock + ) -> None: """Starting an action on an ended call logs a warning and returns a resolved action.""" call.state = CALL_STATE_ENDED action = await call.play([{"type": "tts", "params": {"text": "Hi"}}]) @@ -731,7 +821,9 @@ async def test_ended_call_resolves_gracefully(self, call: Call, mock_client: Mag assert action.is_done is True @pytest.mark.asyncio - async def test_execute_failure_raises_and_cleans_up_action(self, call: Call, mock_client: MagicMock) -> None: + async def test_execute_failure_raises_and_cleans_up_action( + self, call: Call, mock_client: MagicMock + ) -> None: """A2 contract (Wave 1): a non-gone relay error (500) RAISES out of the verb — the developer sees the failure — and the action is removed from _actions + its future rejected (so a concurrent wait() gets the error, @@ -745,7 +837,9 @@ async def test_execute_failure_raises_and_cleans_up_action(self, call: Call, moc assert not call._actions @pytest.mark.asyncio - async def test_call_gone_resolves_action_immediately(self, call: Call, mock_client: MagicMock) -> None: + async def test_call_gone_resolves_action_immediately( + self, call: Call, mock_client: MagicMock + ) -> None: mock_client.execute.side_effect = MockRelayError(404, "Call not found") action = await call.play([{"type": "tts", "params": {"text": "Hi"}}]) assert action.completed is True @@ -761,6 +855,7 @@ async def test_call_gone_resolves_action_immediately(self, call: Call, mock_clie # Event dispatch tests # --------------------------------------------------------------------------- + class TestEventDispatch: @pytest.mark.asyncio async def test_state_event_updates_call_state(self, call: Call) -> None: @@ -782,17 +877,25 @@ async def test_ended_event_resolves_ended_future(self, call: Call) -> None: assert call._ended.done() @pytest.mark.asyncio - async def test_action_resolved_by_event(self, call: Call, mock_client: MagicMock) -> None: + async def test_action_resolved_by_event( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.play( [{"type": "tts", "params": {"text": "Hello"}}], control_id="ctl1", ) assert not action.is_done # Simulate play finished event - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "ctl1", "state": "finished"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "ctl1", + "state": "finished", + }, + } + ) assert action.is_done assert action.completed assert "ctl1" not in call._actions @@ -805,10 +908,16 @@ def handler(event: RelayEvent) -> None: events_received.append(event) call.on(EVENT_CALL_PLAY, handler) - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "ctl1", "state": "playing"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "ctl1", + "state": "playing", + }, + } + ) assert len(events_received) == 1 assert events_received[0].params["state"] == "playing" @@ -816,10 +925,16 @@ def handler(event: RelayEvent) -> None: async def test_wait_for(self, call: Call) -> None: async def send_event_later() -> None: await asyncio.sleep(0.01) - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "ctl1", "state": "finished"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "ctl1", + "state": "finished", + }, + } + ) task = asyncio.create_task(send_event_later()) event = await call.wait_for(EVENT_CALL_PLAY, timeout=2.0) @@ -830,15 +945,27 @@ async def send_event_later() -> None: async def test_wait_for_with_predicate(self, call: Call) -> None: async def send_events() -> None: await asyncio.sleep(0.01) - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "ctl1", "state": "playing"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "ctl1", + "state": "playing", + }, + } + ) await asyncio.sleep(0.01) - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "ctl1", "state": "finished"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "ctl1", + "state": "finished", + }, + } + ) task = asyncio.create_task(send_events()) event = await call.wait_for( @@ -853,10 +980,12 @@ async def send_events() -> None: async def test_wait_for_ended(self, call: Call) -> None: async def end_call() -> None: await asyncio.sleep(0.01) - await call._dispatch_event({ - "event_type": EVENT_CALL_STATE, - "params": {"call_id": "call-1", "call_state": CALL_STATE_ENDED}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_STATE, + "params": {"call_id": "call-1", "call_state": CALL_STATE_ENDED}, + } + ) task = asyncio.create_task(end_call()) event = await call.wait_for_ended(timeout=2.0) @@ -868,28 +997,38 @@ class TestCollectActionEventRouting: """Test that CollectAction only resolves on collect events, not play events.""" @pytest.mark.asyncio - async def test_collect_ignores_play_events(self, call: Call, mock_client: MagicMock) -> None: + async def test_collect_ignores_play_events( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.play_and_collect( [{"type": "tts", "params": {"text": "Press 1"}}], {"digits": {"max": 1}}, control_id="pac1", ) # Simulate play event — should NOT resolve the collect action - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "pac1", "state": "finished"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "pac1", + "state": "finished", + }, + } + ) assert not action.is_done # Simulate collect result — should resolve - await call._dispatch_event({ - "event_type": EVENT_CALL_COLLECT, - "params": { - "call_id": "call-1", - "control_id": "pac1", - "result": {"type": "digit", "params": {"digits": "1"}}, - }, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_COLLECT, + "params": { + "call_id": "call-1", + "control_id": "pac1", + "result": {"type": "digit", "params": {"digits": "1"}}, + }, + } + ) assert action.is_done assert action.result.params["result"]["type"] == "digit" @@ -898,16 +1037,20 @@ class TestDetectActionEventRouting: """Test that DetectAction resolves on first detect result.""" @pytest.mark.asyncio - async def test_detect_resolves_on_first_result(self, call: Call, mock_client: MagicMock) -> None: + async def test_detect_resolves_on_first_result( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.detect({"type": "machine"}, control_id="det1") - await call._dispatch_event({ - "event_type": "calling.call.detect", - "params": { - "call_id": "call-1", - "control_id": "det1", - "detect": {"type": "machine", "params": {"event": "HUMAN"}}, - }, - }) + await call._dispatch_event( + { + "event_type": "calling.call.detect", + "params": { + "call_id": "call-1", + "control_id": "det1", + "detect": {"type": "machine", "params": {"event": "HUMAN"}}, + }, + } + ) assert action.is_done assert action.result is not None assert action.result.params["detect"]["params"]["event"] == "HUMAN" @@ -950,7 +1093,9 @@ async def test_detect_stop(self, call: Call, mock_client: MagicMock) -> None: class TestCollectActionMethods: @pytest.mark.asyncio - async def test_play_and_collect_stop(self, call: Call, mock_client: MagicMock) -> None: + async def test_play_and_collect_stop( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.play_and_collect( [{"type": "tts", "params": {"text": "Press 1"}}], {"digits": {"max": 1}}, @@ -961,7 +1106,9 @@ async def test_play_and_collect_stop(self, call: Call, mock_client: MagicMock) - assert mock_client.execute.call_args[0][0] == "calling.play_and_collect.stop" @pytest.mark.asyncio - async def test_play_and_collect_volume(self, call: Call, mock_client: MagicMock) -> None: + async def test_play_and_collect_volume( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.play_and_collect( [{"type": "tts", "params": {"text": "Press 1"}}], {"digits": {"max": 1}}, @@ -974,7 +1121,9 @@ async def test_play_and_collect_volume(self, call: Call, mock_client: MagicMock) assert params["volume"] == 5.0 @pytest.mark.asyncio - async def test_play_and_collect_with_volume_param(self, call: Call, mock_client: MagicMock) -> None: + async def test_play_and_collect_with_volume_param( + self, call: Call, mock_client: MagicMock + ) -> None: await call.play_and_collect( [{"type": "tts", "params": {"text": "Press 1"}}], {"digits": {"max": 1}}, @@ -984,7 +1133,9 @@ async def test_play_and_collect_with_volume_param(self, call: Call, mock_client: assert params["volume"] == 3.0 @pytest.mark.asyncio - async def test_collect_start_input_timers_method(self, call: Call, mock_client: MagicMock) -> None: + async def test_collect_start_input_timers_method( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.play_and_collect( [{"type": "tts", "params": {"text": "Press 1"}}], {"digits": {"max": 1}}, @@ -992,49 +1143,69 @@ async def test_collect_start_input_timers_method(self, call: Call, mock_client: ) mock_client.execute.reset_mock() await action.start_input_timers() - assert mock_client.execute.call_args[0][0] == "calling.collect.start_input_timers" + assert ( + mock_client.execute.call_args[0][0] == "calling.collect.start_input_timers" + ) class TestStandaloneCollectEventRouting: @pytest.mark.asyncio - async def test_standalone_collect_resolves_on_result(self, call: Call, mock_client: MagicMock) -> None: + async def test_standalone_collect_resolves_on_result( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.collect(digits={"max": 1}, control_id="col1") - await call._dispatch_event({ - "event_type": EVENT_CALL_COLLECT, - "params": { - "call_id": "call-1", - "control_id": "col1", - "result": {"type": "digit", "params": {"digits": "5"}}, - }, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_COLLECT, + "params": { + "call_id": "call-1", + "control_id": "col1", + "result": {"type": "digit", "params": {"digits": "5"}}, + }, + } + ) assert action.is_done @pytest.mark.asyncio - async def test_standalone_collect_ignores_non_collect_events(self, call: Call, mock_client: MagicMock) -> None: + async def test_standalone_collect_ignores_non_collect_events( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.collect(digits={"max": 1}, control_id="col1") - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "col1", "state": "finished"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "col1", + "state": "finished", + }, + } + ) assert not action.is_done @pytest.mark.asyncio - async def test_standalone_collect_resolves_on_terminal_state(self, call: Call, mock_client: MagicMock) -> None: + async def test_standalone_collect_resolves_on_terminal_state( + self, call: Call, mock_client: MagicMock + ) -> None: action = await call.collect(digits={"max": 1}, control_id="col1") - await call._dispatch_event({ - "event_type": EVENT_CALL_COLLECT, - "params": { - "call_id": "call-1", - "control_id": "col1", - "state": "no_input", - }, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_COLLECT, + "params": { + "call_id": "call-1", + "control_id": "col1", + "state": "no_input", + }, + } + ) assert action.is_done class TestCollectOptionalParams: @pytest.mark.asyncio - async def test_collect_with_all_params(self, call: Call, mock_client: MagicMock) -> None: + async def test_collect_with_all_params( + self, call: Call, mock_client: MagicMock + ) -> None: await call.collect( digits={"max": 4}, speech={"language": "en-US"}, @@ -1054,7 +1225,9 @@ async def test_collect_with_all_params(self, call: Call, mock_client: MagicMock) class TestConnectOptionalParams: @pytest.mark.asyncio - async def test_connect_with_all_params(self, call: Call, mock_client: MagicMock) -> None: + async def test_connect_with_all_params( + self, call: Call, mock_client: MagicMock + ) -> None: await call.connect( [[{"type": "phone", "params": {"to_number": "+15551234567"}}]], tag="my-tag", @@ -1071,7 +1244,9 @@ async def test_connect_with_all_params(self, call: Call, mock_client: MagicMock) class TestPayAllParams: @pytest.mark.asyncio - async def test_pay_all_optional_params(self, call: Call, mock_client: MagicMock) -> None: + async def test_pay_all_optional_params( + self, call: Call, mock_client: MagicMock + ) -> None: await call.pay( "https://pay.example.com", control_id="pay1", @@ -1172,7 +1347,9 @@ async def test_transcribe_stop(self, call: Call, mock_client: MagicMock) -> None class TestConferenceAllParams: @pytest.mark.asyncio - async def test_join_conference_all_params(self, call: Call, mock_client: MagicMock) -> None: + async def test_join_conference_all_params( + self, call: Call, mock_client: MagicMock + ) -> None: await call.join_conference( "my_conf", muted=True, @@ -1216,7 +1393,9 @@ async def test_join_conference_all_params(self, call: Call, mock_client: MagicMo class TestEchoOptionalParams: @pytest.mark.asyncio - async def test_echo_with_status_url(self, call: Call, mock_client: MagicMock) -> None: + async def test_echo_with_status_url( + self, call: Call, mock_client: MagicMock + ) -> None: await call.echo(timeout=30.0, status_url="https://example.com/echo") params = mock_client.execute.call_args[0][1] assert params["status_url"] == "https://example.com/echo" @@ -1224,7 +1403,9 @@ async def test_echo_with_status_url(self, call: Call, mock_client: MagicMock) -> class TestAIAllParams: @pytest.mark.asyncio - async def test_ai_with_post_prompt_params(self, call: Call, mock_client: MagicMock) -> None: + async def test_ai_with_post_prompt_params( + self, call: Call, mock_client: MagicMock + ) -> None: await call.ai( control_id="ai1", prompt={"text": "Hello"}, @@ -1246,7 +1427,9 @@ async def test_ai_with_post_prompt_params(self, call: Call, mock_client: MagicMo class TestAmazonBedrockAllParams: @pytest.mark.asyncio - async def test_amazon_bedrock_all_params(self, call: Call, mock_client: MagicMock) -> None: + async def test_amazon_bedrock_all_params( + self, call: Call, mock_client: MagicMock + ) -> None: await call.amazon_bedrock( prompt="You are helpful.", SWAIG={"functions": []}, @@ -1266,7 +1449,9 @@ async def test_amazon_bedrock_all_params(self, call: Call, mock_client: MagicMoc class TestAIMessageAllParams: @pytest.mark.asyncio - async def test_ai_message_with_control_id(self, call: Call, mock_client: MagicMock) -> None: + async def test_ai_message_with_control_id( + self, call: Call, mock_client: MagicMock + ) -> None: await call.ai_message( message_text="Hello", role="system", @@ -1280,8 +1465,12 @@ async def test_ai_message_with_control_id(self, call: Call, mock_client: MagicMo class TestQueueOptionalParams: @pytest.mark.asyncio - async def test_queue_leave_with_status_url(self, call: Call, mock_client: MagicMock) -> None: - await call.queue_leave("support", control_id="q1", status_url="https://example.com/q") + async def test_queue_leave_with_status_url( + self, call: Call, mock_client: MagicMock + ) -> None: + await call.queue_leave( + "support", control_id="q1", status_url="https://example.com/q" + ) params = mock_client.execute.call_args[0][1] assert params["status_url"] == "https://example.com/q" @@ -1292,19 +1481,27 @@ async def test_listener_exception_does_not_crash(self, call: Call) -> None: """Verify that an exception in one event handler is caught and the OTHER handlers still get the event. If exception handling were broken, the second handler would never run.""" + def bad_handler(event: RelayEvent) -> None: raise RuntimeError("handler crashed") good_calls: list[RelayEvent] = [] + def good_handler(event: RelayEvent) -> None: good_calls.append(event) call.on(EVENT_CALL_PLAY, bad_handler) call.on(EVENT_CALL_PLAY, good_handler) - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "ctl1", "state": "playing"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "ctl1", + "state": "playing", + }, + } + ) # The bad handler raised, but the good handler still fired exactly once. assert len(good_calls) == 1 assert good_calls[0].event_type == EVENT_CALL_PLAY @@ -1319,12 +1516,19 @@ async def test_wait_for_timeout_raises(self, call: Call) -> None: @pytest.mark.asyncio async def test_wait_for_no_timeout(self, call: Call) -> None: """wait_for without timeout resolves when event arrives.""" + async def send_later() -> None: await asyncio.sleep(0.01) - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "x", "state": "finished"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "x", + "state": "finished", + }, + } + ) task = asyncio.create_task(send_later()) event = await asyncio.wait_for( @@ -1337,7 +1541,9 @@ async def send_later() -> None: class TestOnCompleted: @pytest.mark.asyncio - async def test_on_completed_sync_callback(self, call: Call, mock_client: MagicMock) -> None: + async def test_on_completed_sync_callback( + self, call: Call, mock_client: MagicMock + ) -> None: results = [] action = await call.play( [{"type": "tts", "params": {"text": "Hi"}}], @@ -1345,35 +1551,51 @@ async def test_on_completed_sync_callback(self, call: Call, mock_client: MagicMo on_completed=lambda event: results.append(event), ) # Simulate play finished - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "ctl1", "state": "finished"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "ctl1", + "state": "finished", + }, + } + ) assert action.is_done assert len(results) == 1 assert results[0].params["state"] == "finished" @pytest.mark.asyncio - async def test_on_completed_async_callback(self, call: Call, mock_client: MagicMock) -> None: + async def test_on_completed_async_callback( + self, call: Call, mock_client: MagicMock + ) -> None: results = [] async def on_done(event: RelayEvent) -> None: results.append(event) - action = await call.play( + await call.play( [{"type": "tts", "params": {"text": "Hi"}}], control_id="ctl1", on_completed=on_done, ) - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "ctl1", "state": "finished"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "ctl1", + "state": "finished", + }, + } + ) await asyncio.sleep(0.01) # let the coroutine task run assert len(results) == 1 @pytest.mark.asyncio - async def test_on_completed_not_called_on_non_terminal(self, call: Call, mock_client: MagicMock) -> None: + async def test_on_completed_not_called_on_non_terminal( + self, call: Call, mock_client: MagicMock + ) -> None: results = [] action = await call.play( [{"type": "tts", "params": {"text": "Hi"}}], @@ -1381,15 +1603,23 @@ async def test_on_completed_not_called_on_non_terminal(self, call: Call, mock_cl on_completed=lambda event: results.append(event), ) # Non-terminal state — callback should NOT fire - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "ctl1", "state": "playing"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "ctl1", + "state": "playing", + }, + } + ) assert not action.is_done assert len(results) == 0 @pytest.mark.asyncio - async def test_on_completed_error_does_not_crash(self, call: Call, mock_client: MagicMock) -> None: + async def test_on_completed_error_does_not_crash( + self, call: Call, mock_client: MagicMock + ) -> None: def bad_callback(event: RelayEvent) -> None: raise RuntimeError("callback error") @@ -1399,29 +1629,46 @@ def bad_callback(event: RelayEvent) -> None: on_completed=bad_callback, ) # Should not raise - await call._dispatch_event({ - "event_type": EVENT_CALL_PLAY, - "params": {"call_id": "call-1", "control_id": "ctl1", "state": "finished"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_PLAY, + "params": { + "call_id": "call-1", + "control_id": "ctl1", + "state": "finished", + }, + } + ) assert action.is_done @pytest.mark.asyncio - async def test_on_completed_on_record(self, call: Call, mock_client: MagicMock) -> None: + async def test_on_completed_on_record( + self, call: Call, mock_client: MagicMock + ) -> None: results = [] from signalwire.relay.constants import EVENT_CALL_RECORD + action = await call.record( control_id="r1", on_completed=lambda event: results.append(event), ) - await call._dispatch_event({ - "event_type": EVENT_CALL_RECORD, - "params": {"call_id": "call-1", "control_id": "r1", "state": "finished"}, - }) + await call._dispatch_event( + { + "event_type": EVENT_CALL_RECORD, + "params": { + "call_id": "call-1", + "control_id": "r1", + "state": "finished", + }, + } + ) assert action.is_done assert len(results) == 1 @pytest.mark.asyncio - async def test_on_completed_on_call_gone(self, call: Call, mock_client: MagicMock) -> None: + async def test_on_completed_on_call_gone( + self, call: Call, mock_client: MagicMock + ) -> None: """on_completed fires even when call is gone (404).""" mock_client.execute.side_effect = MockRelayError(404, "Call not found") results = [] diff --git a/tests/unit/relay/test_client.py b/tests/unit/relay/test_client.py index 90e15079..4b7082b8 100644 --- a/tests/unit/relay/test_client.py +++ b/tests/unit/relay/test_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib import json import os from pathlib import Path @@ -17,11 +18,11 @@ RelayClient, RelayError, _active_clients, - _MAX_CONNECTIONS, + _max_connections, _MAX_QUEUE_SIZE, _SUCCESS_CODE_RE, ) -from signalwire.relay.call import Call, PlayAction +from signalwire.relay.call import Call from signalwire.relay.message import Message from signalwire.relay.constants import ( AGENT_STRING, @@ -31,7 +32,6 @@ EVENT_CALL_STATE, METHOD_SIGNALWIRE_CONNECT, METHOD_SIGNALWIRE_DISCONNECT, - METHOD_SIGNALWIRE_EVENT, METHOD_SIGNALWIRE_PING, PROTOCOL_VERSION, RECONNECT_BACKOFF_FACTOR, @@ -267,7 +267,7 @@ def teardown_method(self) -> None: async def test_sends_signalwire_connect( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + _client, ws = connected_client # Find the connect message connect_msg = None for msg in ws.sent_messages: @@ -286,7 +286,7 @@ async def test_sends_signalwire_connect( async def test_stores_protocol_and_identity( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client assert client._relay_protocol == "test-protocol-abc123" assert client._identity == "test-identity" @@ -294,14 +294,14 @@ async def test_stores_protocol_and_identity( async def test_connected_flag_set( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client assert client._connected is True @pytest.mark.asyncio async def test_recv_task_started( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client assert client._recv_task is not None assert not client._recv_task.done() @@ -309,7 +309,7 @@ async def test_recv_task_started( async def test_ping_task_started( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client assert client._ping_task is not None @pytest.mark.asyncio @@ -364,11 +364,11 @@ async def test_contexts_sent_in_connect(self) -> None: project="p", token="t", contexts=["default", "support"] ) await client.connect() - connect_msg = [ + connect_msg = next( m for m in ws.sent_messages if m.get("method") == METHOD_SIGNALWIRE_CONNECT - ][0] + ) assert connect_msg["params"]["contexts"] == ["default", "support"] await client.disconnect() _active_clients.clear() @@ -387,17 +387,18 @@ def teardown_method(self) -> None: _active_clients.clear() @pytest.mark.asyncio - async def test_limit_of_one_is_enforced(self) -> None: - # Pin the limit to 1 for this test rather than relying on the module - # default: the real-mock relay conftest raises RELAY_MAX_CONNECTIONS to - # 16 (so a single test can hold several live clients), and that env var - # is read once at client-module import — making any assertion about the - # *default* value order-dependent under pytest-xdist. Patching the - # module global makes the limit-enforcement behavior deterministic. + async def test_limit_of_one_is_enforced( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Pin the limit to 1 for this test rather than relying on the ambient + # value: the real-mock relay conftest raises RELAY_MAX_CONNECTIONS to 16 + # (so a single test can hold several live clients). The limit is read + # from the environment at connect time, so setting the var here is + # enough to make the limit-enforcement behavior deterministic. + monkeypatch.setenv("RELAY_MAX_CONNECTIONS", "1") ws1 = AutoAuthMockWebSocket() ws2 = AutoAuthMockWebSocket() with ( - patch("signalwire.relay.client._MAX_CONNECTIONS", 1), patch( "signalwire.relay.client.websockets.connect", new_callable=AsyncMock, @@ -452,7 +453,7 @@ class TestDisconnect: async def test_disconnect_sets_flags( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client await client.disconnect() assert client._closing is True assert client._connected is False @@ -462,18 +463,29 @@ async def test_disconnect_sets_flags( async def test_disconnect_cancels_tasks( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client recv = client._recv_task ping = client._ping_task + assert recv is not None + assert ping is not None await client.disconnect() assert client._recv_task is None assert client._ping_task is None + # The handles were captured so the test can prove the tasks actually + # STOPPED, not merely got dropped from the client — clearing the + # attribute without cancelling would leak both tasks. disconnect() only + # REQUESTS cancellation, so the loop must run for them to observe it. + # `done()` rather than `cancelled()`: _recv_loop catches CancelledError + # and returns normally, so it finishes done-but-not-cancelled. + await asyncio.sleep(0) + assert recv.done() + assert ping.done() @pytest.mark.asyncio async def test_disconnect_cancels_pending_futures( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client loop = asyncio.get_running_loop() fut = loop.create_future() client._pending["test-id"] = fut @@ -485,7 +497,7 @@ async def test_disconnect_cancels_pending_futures( async def test_disconnect_cancels_queued_requests( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client loop = asyncio.get_running_loop() fut = loop.create_future() client._execute_queue.append(({"method": "test"}, fut)) @@ -497,7 +509,7 @@ async def test_disconnect_cancels_queued_requests( async def test_disconnect_removes_from_active_clients( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client assert id(client) in _active_clients await client.disconnect() assert id(client) not in _active_clients @@ -591,7 +603,7 @@ async def test_signalwire_connect_skips_code_check( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: """signalwire.connect responses should not be checked for code field.""" - client, ws = connected_client + client, _ws = connected_client # The auth already succeeded — just verify protocol was stored assert client._relay_protocol == "test-protocol-abc123" @@ -702,7 +714,7 @@ async def test_event_ack_sent( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: """Events should be ACKed back to the server.""" - client, ws = connected_client + _client, ws = connected_client ws.sent_messages.clear() event = make_event( @@ -813,7 +825,7 @@ async def test_ended_call_removed( make_call: Callable[..., Call], ) -> None: client, ws = connected_client - call = make_call(client, call_id="c-end") + make_call(client, call_id="c-end") assert "c-end" in client._calls event = make_event( @@ -829,7 +841,7 @@ async def test_signalwire_disconnect_acked( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: """Server disconnect should be ACKed.""" - client, ws = connected_client + _client, ws = connected_client ws.sent_messages.clear() disconnect_msg = { @@ -855,7 +867,7 @@ class TestPing: async def test_server_ping_gets_pong( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + _client, ws = connected_client ws.sent_messages.clear() ping = make_server_ping(msg_id="ping-1") @@ -945,7 +957,7 @@ async def test_ping_failure_backoff(self) -> None: async def test_force_close( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client client._force_close() assert client._connected is False @@ -970,10 +982,8 @@ async def test_queue_while_disconnected(self) -> None: assert len(client._execute_queue) == 1 # Cancel the future to avoid hanging task.cancel() - try: + with contextlib.suppress(asyncio.CancelledError, RelayError): await task - except (asyncio.CancelledError, RelayError): - pass _active_clients.clear() @pytest.mark.asyncio @@ -1038,10 +1048,8 @@ async def test_queue_overflow(self) -> None: # Cancel all queued futures for f in futures: f.cancel() - try: + with contextlib.suppress(asyncio.CancelledError, RelayError): await f - except (asyncio.CancelledError, RelayError): - pass _active_clients.clear() @@ -1055,28 +1063,26 @@ class TestTimeouts: async def test_execute_timeout_raises( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client - with patch("signalwire.relay.client._EXECUTE_TIMEOUT", 0.05): + with ( + patch("signalwire.relay.client._EXECUTE_TIMEOUT", 0.05), # Don't respond to the request — it should timeout - with pytest.raises(RelayError, match="timeout"): - await client.execute( - "calling.answer", {"node_id": "n1", "call_id": "c1"} - ) + pytest.raises(RelayError, match="timeout"), + ): + await client.execute("calling.answer", {"node_id": "n1", "call_id": "c1"}) @pytest.mark.asyncio async def test_execute_timeout_force_closes( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client with patch("signalwire.relay.client._EXECUTE_TIMEOUT", 0.05): - try: + with contextlib.suppress(RelayError): await client.execute( "calling.answer", {"node_id": "n1", "call_id": "c1"} ) - except RelayError: - pass # Force close should have been called (connected = False) assert client._connected is False @@ -1274,7 +1280,7 @@ class TestRelayProtocolProperty: async def test_relay_protocol_property( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + client, _ws = connected_client assert client.relay_protocol == "test-protocol-abc123" @@ -1287,13 +1293,75 @@ class TestMaxConnectionsEnvVar: def test_invalid_env_var_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: """Invalid RELAY_MAX_CONNECTIONS should fall back to 1.""" _active_clients.clear() - # We can't easily re-execute module-level code, but we can test - # that the regex/parsing logic works by importing the module fresh. - # Instead, just verify the current value is sane. - import signalwire.relay.client as mod + monkeypatch.setenv("RELAY_MAX_CONNECTIONS", "not-a-number") + assert _max_connections() == 1 + _active_clients.clear() + + def test_valid_env_var_is_honoured(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("RELAY_MAX_CONNECTIONS", "7") + assert _max_connections() == 7 + + def test_zero_and_negative_clamp_to_one( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("RELAY_MAX_CONNECTIONS", "0") + assert _max_connections() == 1 + monkeypatch.setenv("RELAY_MAX_CONNECTIONS", "-5") + assert _max_connections() == 1 + + def test_unset_defaults_to_one(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("RELAY_MAX_CONNECTIONS", raising=False) + assert _max_connections() == 1 + + @pytest.mark.asyncio + async def test_env_var_set_after_import_takes_effect( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """RELAY_MAX_CONNECTIONS is read at CONNECT time, not at import time. - assert mod._MAX_CONNECTIONS >= 1 + Regression guard: the limit used to be frozen into a module global at + import, so the documented remedy in the refusal message ("set + RELAY_MAX_CONNECTIONS env var to allow more") could never work for a + process that had already imported the module. Setting the var here — + long after import — must raise the effective limit. + """ _active_clients.clear() + try: + # Limit 1: the second client must be refused. + monkeypatch.setenv("RELAY_MAX_CONNECTIONS", "1") + ws1 = AutoAuthMockWebSocket() + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws1, + ): + c1 = RelayClient(project="p", token="t") + await c1.connect() + + ws2 = AutoAuthMockWebSocket() + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws2, + ): + c2 = RelayClient(project="p", token="t") + with pytest.raises(RuntimeError, match="connection limit reached"): + await c2.connect() + + # Now raise the limit AFTER import — the same second client must + # be accepted. + monkeypatch.setenv("RELAY_MAX_CONNECTIONS", "4") + ws3 = AutoAuthMockWebSocket() + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws3, + ): + c3 = RelayClient(project="p", token="t") + await c3.connect() + assert id(c3) in _active_clients + finally: + _active_clients.clear() # =================================================================== @@ -1376,8 +1444,7 @@ async def close(self) -> None: async def mock_connect(*args: Any, **kwargs: Any) -> CountingMockWS: nonlocal connect_count connect_count += 1 - ws = CountingMockWS() - return ws + return CountingMockWS() with ( patch( @@ -1478,6 +1545,59 @@ async def cancel_connect(*args: Any, **kwargs: Any) -> AutoAuthMockWebSocket: assert client._ws is None _active_clients.clear() + @pytest.mark.asyncio + async def test_run_forever_survives_loop_without_signal_handlers(self) -> None: + """_run_forever must not die where loop.add_signal_handler is unsupported. + + asyncio's loop-level signal handling is Unix-only: on Windows BOTH the + Proactor and Selector loops raise NotImplementedError unconditionally. + Before the guard, that exception escaped on the FIRST statement of + _run_forever(), so RelayClient.run() never reached connect() on Windows + — a real product defect, not a test artifact. + + This reproduces the platform condition directly (patching the loop + method to raise exactly what Windows raises) rather than skipping on + win32, so the contract is covered on every OS the suite runs on. + """ + _active_clients.clear() + connect_count = 0 + + async def mock_connect(*args: Any, **kwargs: Any) -> AutoAuthMockWebSocket: + nonlocal connect_count + connect_count += 1 + return AutoAuthMockWebSocket() + + loop = asyncio.get_running_loop() + + def no_signal_handlers(*args: Any, **kwargs: Any) -> None: + raise NotImplementedError + + with ( + patch( + "signalwire.relay.client.websockets.connect", side_effect=mock_connect + ), + patch("signalwire.relay.client._CLIENT_PING_INTERVAL", 999), + patch.object(loop, "add_signal_handler", no_signal_handlers), + ): + client = RelayClient(project="p", token="t") + + async def stop_after_connect() -> None: + while connect_count < 1: + await asyncio.sleep(0.01) + await asyncio.sleep(0.05) + client._closing = True + if client._ws: + await client._ws.close() + + task = asyncio.ensure_future(client._run_forever()) + stopper = asyncio.ensure_future(stop_after_connect()) + # The bug made this raise NotImplementedError instead of connecting. + await asyncio.wait_for(task, timeout=5.0) + stopper.cancel() + # It got PAST the signal registration and did real work. + assert connect_count >= 1 + _active_clients.clear() + # =================================================================== # PY-4 / A6 — bounded reconnect on PERMANENT auth rejection. @@ -1631,7 +1751,7 @@ async def test_flush_skips_done_futures( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: """Already-done futures in queue should be skipped — line 414.""" - client, ws = connected_client + client, _ws = connected_client loop = asyncio.get_running_loop() done_fut = loop.create_future() done_fut.cancel() # Mark as done @@ -1683,7 +1803,7 @@ async def test_clear_pending_requests( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: """_clear_pending_requests rejects all pending — lines 432-438.""" - client, ws = connected_client + client, _ws = connected_client loop = asyncio.get_running_loop() fut1 = loop.create_future() @@ -1729,10 +1849,8 @@ async def test_recv_loop_connection_closed(self) -> None: # Cancel the existing recv task if client._recv_task: client._recv_task.cancel() - try: + with contextlib.suppress(asyncio.CancelledError): await client._recv_task - except asyncio.CancelledError: - pass # Replace _ws with one that immediately raises ConnectionClosed class ImmediateCloseWS: @@ -1922,7 +2040,7 @@ async def test_send_pong_no_ws( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: """_send_pong with _ws=None should return early — line 613.""" - client, ws = connected_client + client, _ws = connected_client client._ws = None await client._send_pong("test-id") # Should not raise @@ -1931,7 +2049,7 @@ async def test_send_event_ack_no_ws( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: """_send_event_ack with _ws=None should return early — line 624.""" - client, ws = connected_client + client, _ws = connected_client client._ws = None await client._send_event_ack("test-id") # Should not raise @@ -1988,18 +2106,26 @@ async def test_ping_loop_max_failures_force_close(self) -> None: return_value=ws, ), patch("signalwire.relay.client._CLIENT_PING_INTERVAL", 0.01), - patch("signalwire.relay.client._EXECUTE_TIMEOUT", 0.01), patch("signalwire.relay.client._MAX_PING_FAILURES", 1), patch("signalwire.relay.client.RECONNECT_MIN_DELAY", 0.01), ): client = RelayClient(project="p", token="t") + # connect() must NOT run under the 10ms ping timeout. The auth + # round-trip needs several event-loop turns (AutoAuthMockWebSocket + # queues the reply, then _recv_task has to be scheduled and drain + # it), and _send_request reads _EXECUTE_TIMEOUT at call time. A 10ms + # deadline is below the Windows asyncio timer granularity (~15.6ms + # clock tick), so the connect request could time out before the loop + # ever ran the recv task — "Request timeout for signalwire.connect". + # Shorten the timeout only for the pings this test is about. await client.connect() - # Don't respond to pings — they'll timeout and trigger force_close - await asyncio.sleep(0.3) + with patch("signalwire.relay.client._EXECUTE_TIMEOUT", 0.01): + # Don't respond to pings — they'll timeout and trigger force_close + await asyncio.sleep(0.3) - # After max failures, should have force-closed - assert client._connected is False + # After max failures, should have force-closed + assert client._connected is False await client.disconnect() _active_clients.clear() @@ -2011,7 +2137,7 @@ async def test_check_ping_timeout_fires( flip _connected, must NOT close the websocket, and must NOT mutate the ping-failure counter — the actual probing is the client ping loop's job.""" - client, ws = connected_client + client, _ws = connected_client # Capture state before the handler runs. was_connected = client._connected prior_failures = client._ping_failures @@ -2036,7 +2162,7 @@ async def test_safe_send_no_ws( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: """_safe_send with _ws=None should do nothing.""" - client, ws = connected_client + client, _ws = connected_client loop = asyncio.get_running_loop() future = loop.create_future() client._ws = None @@ -2099,7 +2225,7 @@ async def test_jwt_auth_sends_jwt_token(self) -> None: async def test_legacy_auth_sends_project_token( self, connected_client: tuple[RelayClient, AutoAuthMockWebSocket] ) -> None: - client, ws = connected_client + _client, ws = connected_client connect_msg = None for msg in ws.sent_messages: if msg.get("method") == METHOD_SIGNALWIRE_CONNECT: diff --git a/tests/unit/relay/test_client_dial.py b/tests/unit/relay/test_client_dial.py index b4f55b79..f58bc643 100644 --- a/tests/unit/relay/test_client_dial.py +++ b/tests/unit/relay/test_client_dial.py @@ -6,11 +6,10 @@ """ import asyncio -import json from typing import Any import pytest -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from signalwire.relay.client import RelayClient, RelayError from signalwire.relay.call import Call @@ -25,6 +24,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_client(**kwargs: Any) -> RelayClient: """Create a RelayClient with mocked internals for unit testing.""" client = RelayClient(project="test-proj", token="test-token", **kwargs) @@ -35,8 +35,12 @@ def _make_client(**kwargs: Any) -> RelayClient: return client -def _make_dial_event(tag: str, dial_state: str, call_id: str = "winner-call-id", - node_id: str = "winner-node-id") -> dict[str, Any]: +def _make_dial_event( + tag: str, + dial_state: str, + call_id: str = "winner-call-id", + node_id: str = "winner-node-id", +) -> dict[str, Any]: """Build a calling.call.dial event payload.""" return { "event_type": EVENT_CALL_DIAL, @@ -48,18 +52,22 @@ def _make_dial_event(tag: str, dial_state: str, call_id: str = "winner-call-id", "call_id": call_id, "node_id": node_id, "tag": tag, - "device": {"type": "phone", "params": { - "from_number": "+15551234567", - "to_number": "+15559876543", - }}, + "device": { + "type": "phone", + "params": { + "from_number": "+15551234567", + "to_number": "+15559876543", + }, + }, "dial_winner": True, }, }, } -def _make_state_event(call_id: str, tag: str, call_state: str, - node_id: str = "node-1") -> dict[str, Any]: +def _make_state_event( + call_id: str, tag: str, call_state: str, node_id: str = "node-1" +) -> dict[str, Any]: """Build a calling.call.state event payload.""" return { "event_type": EVENT_CALL_STATE, @@ -68,10 +76,13 @@ def _make_state_event(call_id: str, tag: str, call_state: str, "call_id": call_id, "tag": tag, "call_state": call_state, - "device": {"type": "phone", "params": { - "from_number": "+15551234567", - "to_number": "+15559876543", - }}, + "device": { + "type": "phone", + "params": { + "from_number": "+15551234567", + "to_number": "+15559876543", + }, + }, }, } @@ -80,6 +91,7 @@ def _make_state_event(call_id: str, tag: str, call_state: str, # Tests for _handle_dial_event # --------------------------------------------------------------------------- + class TestHandleDialEvent: @pytest.mark.asyncio async def test_dial_answered_resolves_future(self) -> None: @@ -154,6 +166,7 @@ async def test_dial_unknown_tag_ignored(self) -> None: # Tests for _handle_event routing with dial # --------------------------------------------------------------------------- + class TestHandleEventDialRouting: @pytest.mark.asyncio async def test_state_event_creates_call_for_pending_dial(self) -> None: @@ -210,13 +223,19 @@ async def test_dial_event_uses_existing_call(self) -> None: client._dial_calls_by_tag[tag] = [] # State events create the call - await client._handle_event(_make_state_event("winner-id", tag, "created", "node-win")) - await client._handle_event(_make_state_event("winner-id", tag, "answered", "node-win")) + await client._handle_event( + _make_state_event("winner-id", tag, "created", "node-win") + ) + await client._handle_event( + _make_state_event("winner-id", tag, "answered", "node-win") + ) assert "winner-id" in client._calls # Dial answered event resolves with existing call - payload = _make_dial_event(tag, "answered", call_id="winner-id", node_id="node-win") + payload = _make_dial_event( + tag, "answered", call_id="winner-id", node_id="node-win" + ) await client._handle_event(payload) assert fut.done() @@ -248,6 +267,7 @@ async def test_ended_call_cleaned_up(self) -> None: # Tests for _handle_event with inbound calls # --------------------------------------------------------------------------- + class TestHandleEventInbound: @pytest.mark.asyncio async def test_inbound_call_creates_call_and_invokes_handler(self) -> None: @@ -282,6 +302,7 @@ async def handler(call: Call) -> None: # Tests for event routing by call_id # --------------------------------------------------------------------------- + class TestHandleEventCallIdRouting: @pytest.mark.asyncio async def test_event_routes_to_call_by_call_id(self) -> None: @@ -301,10 +322,12 @@ async def test_event_routes_to_call_by_call_id(self) -> None: dispatched = [] call.on("calling.call.play", lambda e: dispatched.append(e)) - await client._handle_event({ - "event_type": "calling.call.play", - "params": {"call_id": "c1", "control_id": "ctl1", "state": "playing"}, - }) + await client._handle_event( + { + "event_type": "calling.call.play", + "params": {"call_id": "c1", "control_id": "ctl1", "state": "playing"}, + } + ) assert len(dispatched) == 1 @@ -316,10 +339,16 @@ async def test_unknown_call_id_ignored(self) -> None: # Sanity: nothing registered. assert client._calls == {} - await client._handle_event({ - "event_type": "calling.call.play", - "params": {"call_id": "unknown-id", "control_id": "ctl1", "state": "playing"}, - }) + await client._handle_event( + { + "event_type": "calling.call.play", + "params": { + "call_id": "unknown-id", + "control_id": "ctl1", + "state": "playing", + }, + } + ) # Unknown call_id must NOT be added to the registry as a side effect. assert "unknown-id" not in client._calls assert client._calls == {} @@ -329,6 +358,7 @@ async def test_unknown_call_id_ignored(self) -> None: # Tests for _register_dial_leg # --------------------------------------------------------------------------- + class TestRegisterDialLeg: @pytest.mark.asyncio async def test_creates_and_registers_call(self) -> None: @@ -356,6 +386,7 @@ async def test_creates_and_registers_call(self) -> None: # Tests for disconnect cleanup # --------------------------------------------------------------------------- + class TestDisconnectCleanup: @pytest.mark.asyncio async def test_disconnect_cancels_pending_dials(self) -> None: diff --git a/tests/unit/relay/test_connect_mock.py b/tests/unit/relay/test_connect_mock.py index fa129db6..12ceda24 100644 --- a/tests/unit/relay/test_connect_mock.py +++ b/tests/unit/relay/test_connect_mock.py @@ -18,13 +18,11 @@ from __future__ import annotations -import asyncio -import os from typing import Any import pytest -from signalwire.relay.client import RelayClient, RelayError, _active_clients +from signalwire.relay.client import RelayClient, _active_clients from signalwire.relay.constants import ( AGENT_STRING, METHOD_SIGNALWIRE_CONNECT, @@ -134,7 +132,8 @@ async def test_reconnect_with_protocol_string_includes_protocol_in_frame( # The second connect frame must carry the same protocol field. connect_frames = [ - e for e in mock_relay.journal_recv(method=METHOD_SIGNALWIRE_CONNECT) + e + for e in mock_relay.journal_recv(method=METHOD_SIGNALWIRE_CONNECT) if e.frame["params"].get("protocol") == issued["protocol"] ] assert connect_frames, ( @@ -251,7 +250,8 @@ async def test_connect_with_jwt_carries_jwt_on_wire( # client, so the shared mock's journal also holds connect frames from other # tests/sessions. Identify ours by the unique jwt_token we sent. jwt_connects = [ - e for e in mock_relay.journal_recv(method=METHOD_SIGNALWIRE_CONNECT) + e + for e in mock_relay.journal_recv(method=METHOD_SIGNALWIRE_CONNECT) if e.frame["params"].get("authentication", {}).get("jwt_token") == "fake-jwt-eyJ.AaaA.BbB" ] diff --git a/tests/unit/relay/test_event.py b/tests/unit/relay/test_event.py index 0e86784e..e14804b2 100644 --- a/tests/unit/relay/test_event.py +++ b/tests/unit/relay/test_event.py @@ -1,6 +1,5 @@ """Unit tests for relay event parsing and typed event classes.""" -import pytest from signalwire.relay.event import ( RelayEvent, CallStateEvent, @@ -138,7 +137,11 @@ def test_url_from_nested_record(self) -> None: "call_id": "c1", "control_id": "ctl1", "state": "finished", - "record": {"url": "https://nested.com/rec.mp3", "duration": 10.0, "size": 5000}, + "record": { + "url": "https://nested.com/rec.mp3", + "duration": 10.0, + "size": 5000, + }, }, } event = RecordEvent.from_payload(payload) @@ -155,7 +158,10 @@ def test_from_payload(self) -> None: "call_id": "c1", "control_id": "ctl1", "state": "finished", - "result": {"type": "digit", "params": {"digits": "1234", "terminator": "#"}}, + "result": { + "type": "digit", + "params": {"digits": "1234", "terminator": "#"}, + }, "final": True, }, } @@ -426,7 +432,10 @@ def test_known_event_types_return_typed(self) -> None: assert isinstance(event, cls) def test_unknown_event_type_returns_base(self) -> None: - payload = {"event_type": "calling.call.unknown_future_event", "params": {"call_id": "c1"}} + payload = { + "event_type": "calling.call.unknown_future_event", + "params": {"call_id": "c1"}, + } event = parse_event(payload) assert type(event) is RelayEvent assert event.event_type == "calling.call.unknown_future_event" @@ -611,6 +620,7 @@ def test_from_payload_empty_params(self) -> None: # returned typed event so the behaviour is exercised, not just enumerated. # --------------------------------------------------------------------------- + class TestParseEventBehavior: """Direct tests on parse_event() output asserting on resulting event class and field values. Complements the enumeration-based tests in TestParseEvent diff --git a/tests/unit/relay/test_event_dispatch_mock.py b/tests/unit/relay/test_event_dispatch_mock.py index 187ab954..17340a70 100644 --- a/tests/unit/relay/test_event_dispatch_mock.py +++ b/tests/unit/relay/test_event_dispatch_mock.py @@ -29,7 +29,6 @@ from signalwire.relay.client import RelayClient, _active_clients from signalwire.relay.call import Call from signalwire.relay.event import RelayEvent -from signalwire.relay.constants import METHOD_SIGNALWIRE_EVENT from .conftest import _MockRelayHarness, _RELAY_MOCK_AVAILABLE @@ -249,10 +248,9 @@ async def test_event_ack_sent_back_to_server( # (server side received it) with id == evt_id and a result key. j = mock_relay.journal() acks = [ - e for e in j - if e.direction == "recv" - and e.frame.get("id") == evt_id - and "result" in e.frame + e + for e in j + if e.direction == "recv" and e.frame.get("id") == evt_id and "result" in e.frame ] assert acks, ( f"no event ACK with id={evt_id!r} found in journal; saw recv frames=" @@ -293,7 +291,14 @@ async def test_dial_event_routes_via_tag_when_no_top_level_call_id( device={"type": "phone", "params": {}}, ) call = await client.dial( - [[{"type": "phone", "params": {"to_number": "+1", "from_number": "+2"}}]], + [ + [ + { + "type": "phone", + "params": {"to_number": "+1", "from_number": "+2"}, + } + ] + ], tag="ec-tag-route", dial_timeout=5.0, ) @@ -333,12 +338,15 @@ async def test_server_ping_acked_by_sdk( j = mock_relay.journal() pongs = [ - e for e in j + e + for e in j if e.direction == "recv" and e.frame.get("id") == ping_id and "result" in e.frame ] - assert pongs, f"SDK did not respond to ping; recv frames seen with id={ping_id!r}: {[e.frame for e in j if e.direction == 'recv' and e.frame.get('id') == ping_id]}" + assert pongs, ( + f"SDK did not respond to ping; recv frames seen with id={ping_id!r}: {[e.frame for e in j if e.direction == 'recv' and e.frame.get('id') == ping_id]}" + ) # --------------------------------------------------------------------------- diff --git a/tests/unit/relay/test_message.py b/tests/unit/relay/test_message.py index c400b6f6..ca206542 100644 --- a/tests/unit/relay/test_message.py +++ b/tests/unit/relay/test_message.py @@ -4,21 +4,16 @@ import asyncio import json -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest -import pytest_asyncio -from signalwire.relay.client import RelayClient, RelayError, _active_clients +from signalwire.relay.client import RelayClient, _active_clients from signalwire.relay.event import RelayEvent from signalwire.relay.message import Message from signalwire.relay.constants import ( EVENT_MESSAGING_RECEIVE, EVENT_MESSAGING_STATE, - MESSAGE_STATE_DELIVERED, - MESSAGE_STATE_FAILED, - MESSAGE_STATE_QUEUED, - MESSAGE_STATE_SENT, ) from .conftest import ( @@ -33,6 +28,7 @@ # Message class unit tests # --------------------------------------------------------------------------- + class TestMessage: """Tests for the Message data class and state tracking.""" @@ -56,20 +52,24 @@ async def test_initial_state(self) -> None: @pytest.mark.asyncio async def test_dispatch_event_updates_state(self) -> None: msg = Message(message_id="msg-1", state="queued") - await msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": {"message_id": "msg-1", "message_state": "sent"}, - }) + await msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": {"message_id": "msg-1", "message_state": "sent"}, + } + ) assert msg.state == "sent" assert not msg.is_done @pytest.mark.asyncio async def test_dispatch_terminal_state_resolves(self) -> None: msg = Message(message_id="msg-1", state="queued") - await msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": {"message_id": "msg-1", "message_state": "delivered"}, - }) + await msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": {"message_id": "msg-1", "message_state": "delivered"}, + } + ) assert msg.state == "delivered" assert msg.is_done assert msg.result is not None @@ -77,14 +77,16 @@ async def test_dispatch_terminal_state_resolves(self) -> None: @pytest.mark.asyncio async def test_dispatch_failed_state(self) -> None: msg = Message(message_id="msg-1", state="queued") - await msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": { - "message_id": "msg-1", - "message_state": "failed", - "reason": "spam", - }, - }) + await msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": { + "message_id": "msg-1", + "message_state": "failed", + "reason": "spam", + }, + } + ) assert msg.state == "failed" assert msg.reason == "spam" assert msg.is_done @@ -95,13 +97,18 @@ async def test_wait_returns_terminal_event(self) -> None: async def deliver() -> None: await asyncio.sleep(0.01) - await msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": {"message_id": "msg-1", "message_state": "delivered"}, - }) + await msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": {"message_id": "msg-1", "message_state": "delivered"}, + } + ) - asyncio.ensure_future(deliver()) + # Hold the reference: an un-referenced task can be garbage-collected + # mid-flight, and awaiting it surfaces any exception it raised. + deliver_task = asyncio.ensure_future(deliver()) event = await msg.wait(timeout=2.0) + await deliver_task assert event.params["message_state"] == "delivered" @pytest.mark.asyncio @@ -116,10 +123,12 @@ async def test_on_completed_callback(self) -> None: msg = Message(message_id="msg-1", state="queued") msg._on_completed = lambda event: results.append(event) - await msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": {"message_id": "msg-1", "message_state": "delivered"}, - }) + await msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": {"message_id": "msg-1", "message_state": "delivered"}, + } + ) assert len(results) == 1 assert results[0].params["message_state"] == "delivered" @@ -133,10 +142,12 @@ async def on_done(event: RelayEvent) -> None: msg._on_completed = on_done - await msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": {"message_id": "msg-1", "message_state": "delivered"}, - }) + await msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": {"message_id": "msg-1", "message_state": "delivered"}, + } + ) await asyncio.sleep(0.01) # let the ensure_future run assert len(results) == 1 @@ -146,10 +157,12 @@ async def test_on_completed_error_is_caught(self) -> None: msg._on_completed = lambda event: 1 / 0 # raises ZeroDivisionError # Should not raise - await msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": {"message_id": "msg-1", "message_state": "delivered"}, - }) + await msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": {"message_id": "msg-1", "message_state": "delivered"}, + } + ) assert msg.is_done @pytest.mark.asyncio @@ -158,10 +171,12 @@ async def test_listener_called_on_state_change(self) -> None: msg = Message(message_id="msg-1", state="queued") msg.on(lambda event: events.append(event)) - await msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": {"message_id": "msg-1", "message_state": "sent"}, - }) + await msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": {"message_id": "msg-1", "message_state": "sent"}, + } + ) assert len(events) == 1 @pytest.mark.asyncio @@ -170,10 +185,12 @@ async def test_listener_error_is_caught(self) -> None: msg.on(lambda event: 1 / 0) # Should not raise - await msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": {"message_id": "msg-1", "message_state": "sent"}, - }) + await msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": {"message_id": "msg-1", "message_state": "sent"}, + } + ) assert msg.state == "sent" @pytest.mark.asyncio @@ -194,6 +211,7 @@ async def test_repr(self) -> None: # Client send_message tests # --------------------------------------------------------------------------- + class TestSendMessage: """Tests for RelayClient.send_message().""" @@ -202,28 +220,39 @@ async def test_send_message_basic(self) -> None: _active_clients.clear() ws = AutoAuthMockWebSocket(auto_reply_all=True) - with patch("signalwire.relay.client.websockets.connect", - new_callable=AsyncMock, return_value=ws): + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws, + ): client = RelayClient(project="test-project", token="test-token") await client.connect() # Override auto_reply to include message_id - original_send = ws.send - async def custom_send(raw: str) -> None: await MockWebSocket.send(ws, raw) msg = json.loads(raw) if msg.get("method") == "messaging.send": - ws.feed_message(make_jsonrpc_response(msg["id"], { - "code": "200", - "message": "Message accepted", - "message_id": "msg-abc123", - })) + ws.feed_message( + make_jsonrpc_response( + msg["id"], + { + "code": "200", + "message": "Message accepted", + "message_id": "msg-abc123", + }, + ) + ) elif msg.get("method") == "signalwire.connect": - ws.feed_message(make_jsonrpc_response(msg["id"], { - "protocol": "test-protocol", - "identity": "test-identity", - })) + ws.feed_message( + make_jsonrpc_response( + msg["id"], + { + "protocol": "test-protocol", + "identity": "test-identity", + }, + ) + ) # Simpler approach: just use auto_reply_all which returns code 200 message = await client.send_message( @@ -240,7 +269,9 @@ async def custom_send(raw: str) -> None: assert message.state == "queued" # Verify the RPC was sent correctly - send_msgs = [m for m in ws.sent_messages if m.get("method") == "messaging.send"] + send_msgs = [ + m for m in ws.sent_messages if m.get("method") == "messaging.send" + ] assert len(send_msgs) == 1 params = send_msgs[0]["params"] assert params["to_number"] == "+15552222222" @@ -255,8 +286,11 @@ async def test_send_message_with_media(self) -> None: _active_clients.clear() ws = AutoAuthMockWebSocket(auto_reply_all=True) - with patch("signalwire.relay.client.websockets.connect", - new_callable=AsyncMock, return_value=ws): + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws, + ): client = RelayClient(project="test-project", token="test-token") await client.connect() @@ -268,7 +302,9 @@ async def test_send_message_with_media(self) -> None: assert message.media == ["https://example.com/image.jpg"] - send_msgs = [m for m in ws.sent_messages if m.get("method") == "messaging.send"] + send_msgs = [ + m for m in ws.sent_messages if m.get("method") == "messaging.send" + ] assert send_msgs[0]["params"]["media"] == ["https://example.com/image.jpg"] assert "body" not in send_msgs[0]["params"] @@ -280,8 +316,11 @@ async def test_send_message_with_all_params(self) -> None: _active_clients.clear() ws = AutoAuthMockWebSocket(auto_reply_all=True) - with patch("signalwire.relay.client.websockets.connect", - new_callable=AsyncMock, return_value=ws): + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws, + ): client = RelayClient(project="test-project", token="test-token") await client.connect() @@ -298,7 +337,9 @@ async def test_send_message_with_all_params(self) -> None: assert message.tags == ["vip", "support"] assert message.context == "my_context" - send_msgs = [m for m in ws.sent_messages if m.get("method") == "messaging.send"] + send_msgs = [ + m for m in ws.sent_messages if m.get("method") == "messaging.send" + ] params = send_msgs[0]["params"] assert params["tags"] == ["vip", "support"] assert params["region"] == "us" @@ -312,8 +353,11 @@ async def test_send_message_requires_body_or_media(self) -> None: _active_clients.clear() ws = AutoAuthMockWebSocket(auto_reply_all=True) - with patch("signalwire.relay.client.websockets.connect", - new_callable=AsyncMock, return_value=ws): + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws, + ): client = RelayClient(project="test-project", token="test-token") await client.connect() @@ -331,8 +375,11 @@ async def test_send_message_on_completed(self) -> None: _active_clients.clear() ws = AutoAuthMockWebSocket(auto_reply_all=True) - with patch("signalwire.relay.client.websockets.connect", - new_callable=AsyncMock, return_value=ws): + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws, + ): client = RelayClient(project="test-project", token="test-token") await client.connect() @@ -354,6 +401,7 @@ async def test_send_message_on_completed(self) -> None: # Client event routing tests # --------------------------------------------------------------------------- + class TestMessagingEventRouting: """Tests for messaging event dispatch in RelayClient.""" @@ -362,8 +410,11 @@ async def test_inbound_message_routing(self) -> None: _active_clients.clear() ws = AutoAuthMockWebSocket(auto_reply_all=True) - with patch("signalwire.relay.client.websockets.connect", - new_callable=AsyncMock, return_value=ws): + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws, + ): client = RelayClient(project="test-project", token="test-token") received_messages = [] @@ -375,17 +426,22 @@ async def handle_message(message: Message) -> None: await client.connect() # Inject an inbound message event - ws.feed_message(make_event(EVENT_MESSAGING_RECEIVE, { - "message_id": "msg-inbound-1", - "context": "default", - "direction": "inbound", - "from_number": "+15553333333", - "to_number": "+15551111111", - "body": "Hi there", - "media": [], - "segments": 1, - "message_state": "received", - })) + ws.feed_message( + make_event( + EVENT_MESSAGING_RECEIVE, + { + "message_id": "msg-inbound-1", + "context": "default", + "direction": "inbound", + "from_number": "+15553333333", + "to_number": "+15551111111", + "body": "Hi there", + "media": [], + "segments": 1, + "message_state": "received", + }, + ) + ) # Give the event loop time to process await asyncio.sleep(0.05) @@ -408,19 +464,27 @@ async def test_inbound_message_no_handler(self) -> None: _active_clients.clear() ws = AutoAuthMockWebSocket(auto_reply_all=True) - with patch("signalwire.relay.client.websockets.connect", - new_callable=AsyncMock, return_value=ws): + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws, + ): client = RelayClient(project="test-project", token="test-token") await client.connect() # No handler registered — should not crash - ws.feed_message(make_event(EVENT_MESSAGING_RECEIVE, { - "message_id": "msg-inbound-2", - "from_number": "+15553333333", - "to_number": "+15551111111", - "body": "Hello", - "message_state": "received", - })) + ws.feed_message( + make_event( + EVENT_MESSAGING_RECEIVE, + { + "message_id": "msg-inbound-2", + "from_number": "+15553333333", + "to_number": "+15551111111", + "body": "Hello", + "message_state": "received", + }, + ) + ) await asyncio.sleep(0.05) # No crash is the assertion @@ -434,8 +498,11 @@ async def test_outbound_message_state_routing(self) -> None: _active_clients.clear() ws = AutoAuthMockWebSocket(auto_reply_all=True) - with patch("signalwire.relay.client.websockets.connect", - new_callable=AsyncMock, return_value=ws): + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws, + ): client = RelayClient(project="test-project", token="test-token") await client.connect() @@ -450,18 +517,28 @@ async def test_outbound_message_state_routing(self) -> None: client._messages["msg-out-1"] = message # Send state updates - ws.feed_message(make_event(EVENT_MESSAGING_STATE, { - "message_id": "msg-out-1", - "message_state": "sent", - })) + ws.feed_message( + make_event( + EVENT_MESSAGING_STATE, + { + "message_id": "msg-out-1", + "message_state": "sent", + }, + ) + ) await asyncio.sleep(0.05) assert message.state == "sent" assert not message.is_done - ws.feed_message(make_event(EVENT_MESSAGING_STATE, { - "message_id": "msg-out-1", - "message_state": "delivered", - })) + ws.feed_message( + make_event( + EVENT_MESSAGING_STATE, + { + "message_id": "msg-out-1", + "message_state": "delivered", + }, + ) + ) await asyncio.sleep(0.05) assert message.state == "delivered" assert message.is_done @@ -478,15 +555,23 @@ async def test_state_event_for_unknown_message(self) -> None: _active_clients.clear() ws = AutoAuthMockWebSocket(auto_reply_all=True) - with patch("signalwire.relay.client.websockets.connect", - new_callable=AsyncMock, return_value=ws): + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws, + ): client = RelayClient(project="test-project", token="test-token") await client.connect() - ws.feed_message(make_event(EVENT_MESSAGING_STATE, { - "message_id": "msg-unknown", - "message_state": "delivered", - })) + ws.feed_message( + make_event( + EVENT_MESSAGING_STATE, + { + "message_id": "msg-unknown", + "message_state": "delivered", + }, + ) + ) await asyncio.sleep(0.05) # No crash is the assertion @@ -499,8 +584,11 @@ async def test_message_handler_error_is_caught(self) -> None: _active_clients.clear() ws = AutoAuthMockWebSocket(auto_reply_all=True) - with patch("signalwire.relay.client.websockets.connect", - new_callable=AsyncMock, return_value=ws): + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws, + ): client = RelayClient(project="test-project", token="test-token") @client.on_message @@ -509,13 +597,18 @@ async def handle_message(message: Message) -> None: await client.connect() - ws.feed_message(make_event(EVENT_MESSAGING_RECEIVE, { - "message_id": "msg-err", - "from_number": "+15553333333", - "to_number": "+15551111111", - "body": "Hello", - "message_state": "received", - })) + ws.feed_message( + make_event( + EVENT_MESSAGING_RECEIVE, + { + "message_id": "msg-err", + "from_number": "+15553333333", + "to_number": "+15551111111", + "body": "Hello", + "message_state": "received", + }, + ) + ) await asyncio.sleep(0.05) # No crash — handler error was caught @@ -529,8 +622,11 @@ async def test_inbound_message_with_media_and_tags(self) -> None: _active_clients.clear() ws = AutoAuthMockWebSocket(auto_reply_all=True) - with patch("signalwire.relay.client.websockets.connect", - new_callable=AsyncMock, return_value=ws): + with patch( + "signalwire.relay.client.websockets.connect", + new_callable=AsyncMock, + return_value=ws, + ): client = RelayClient(project="test-project", token="test-token") received = [] @@ -541,22 +637,33 @@ async def handle_message(message: Message) -> None: await client.connect() - ws.feed_message(make_event(EVENT_MESSAGING_RECEIVE, { - "message_id": "msg-mms", - "context": "support", - "direction": "inbound", - "from_number": "+15553333333", - "to_number": "+15551111111", - "body": "Check this out", - "media": ["https://example.com/photo.jpg", "https://example.com/doc.pdf"], - "segments": 2, - "message_state": "received", - "tags": ["vip"], - })) + ws.feed_message( + make_event( + EVENT_MESSAGING_RECEIVE, + { + "message_id": "msg-mms", + "context": "support", + "direction": "inbound", + "from_number": "+15553333333", + "to_number": "+15551111111", + "body": "Check this out", + "media": [ + "https://example.com/photo.jpg", + "https://example.com/doc.pdf", + ], + "segments": 2, + "message_state": "received", + "tags": ["vip"], + }, + ) + ) await asyncio.sleep(0.05) msg = received[0] - assert msg.media == ["https://example.com/photo.jpg", "https://example.com/doc.pdf"] + assert msg.media == [ + "https://example.com/photo.jpg", + "https://example.com/doc.pdf", + ] assert msg.segments == 2 assert msg.tags == ["vip"] assert msg.context == "support" @@ -571,6 +678,7 @@ async def handle_message(message: Message) -> None: # `msg = json.loads(raw)` inside an inner function). # --------------------------------------------------------------------------- + class TestMessageProperties: """Direct property access on a uniquely-named Message variable.""" @@ -585,10 +693,12 @@ async def test_message_is_done_initial(self) -> None: @pytest.mark.asyncio async def test_message_is_done_after_terminal(self) -> None: outbound_msg = Message(message_id="m-prop-2", state="queued") - await outbound_msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": {"message_id": "m-prop-2", "message_state": "delivered"}, - }) + await outbound_msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": {"message_id": "m-prop-2", "message_state": "delivered"}, + } + ) assert outbound_msg.is_done is True @pytest.mark.asyncio @@ -600,10 +710,12 @@ async def test_message_result_initial(self) -> None: @pytest.mark.asyncio async def test_message_result_after_terminal(self) -> None: outbound_msg = Message(message_id="m-prop-4", state="queued") - await outbound_msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": {"message_id": "m-prop-4", "message_state": "delivered"}, - }) + await outbound_msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": {"message_id": "m-prop-4", "message_state": "delivered"}, + } + ) # The result should be the terminal RelayEvent terminal_event = outbound_msg.result assert terminal_event is not None @@ -618,10 +730,12 @@ async def test_message_on_registers_listener(self) -> None: outbound_msg = Message(message_id="m-on-1", state="queued") events_seen: list[RelayEvent] = [] outbound_msg.on(lambda event: events_seen.append(event)) - await outbound_msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": {"message_id": "m-on-1", "message_state": "sent"}, - }) + await outbound_msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": {"message_id": "m-on-1", "message_state": "sent"}, + } + ) assert len(events_seen) == 1 assert events_seen[0].params["message_state"] == "sent" @@ -632,10 +746,12 @@ async def test_message_on_multiple_listeners(self) -> None: events_b: list[RelayEvent] = [] outbound_msg.on(lambda event: events_a.append(event)) outbound_msg.on(lambda event: events_b.append(event)) - await outbound_msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": {"message_id": "m-on-2", "message_state": "sent"}, - }) + await outbound_msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": {"message_id": "m-on-2", "message_state": "sent"}, + } + ) assert len(events_a) == 1 assert len(events_b) == 1 @@ -649,16 +765,21 @@ async def test_message_wait_returns_terminal_event(self) -> None: async def deliver_later() -> None: await asyncio.sleep(0.01) - await outbound_msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": { - "message_id": "m-wait-1", - "message_state": "delivered", - }, - }) + await outbound_msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": { + "message_id": "m-wait-1", + "message_state": "delivered", + }, + } + ) - asyncio.ensure_future(deliver_later()) + # Hold the reference: an un-referenced task can be garbage-collected + # mid-flight, and awaiting it surfaces any exception it raised. + deliver_task = asyncio.ensure_future(deliver_later()) terminal_event = await outbound_msg.wait(timeout=2.0) + await deliver_task assert terminal_event.params["message_state"] == "delivered" @pytest.mark.asyncio @@ -671,13 +792,15 @@ async def test_message_wait_timeout_raises(self) -> None: async def test_message_wait_no_timeout(self) -> None: """wait() without timeout returns immediately if already done.""" outbound_msg = Message(message_id="m-wait-3", state="queued") - await outbound_msg._dispatch_event({ - "event_type": EVENT_MESSAGING_STATE, - "params": { - "message_id": "m-wait-3", - "message_state": "delivered", - }, - }) + await outbound_msg._dispatch_event( + { + "event_type": EVENT_MESSAGING_STATE, + "params": { + "message_id": "m-wait-3", + "message_state": "delivered", + }, + } + ) terminal_event = await outbound_msg.wait() assert terminal_event.params["message_state"] == "delivered" diff --git a/tests/unit/relay/test_messaging_mock.py b/tests/unit/relay/test_messaging_mock.py index 261c68fa..99352b93 100644 --- a/tests/unit/relay/test_messaging_mock.py +++ b/tests/unit/relay/test_messaging_mock.py @@ -71,7 +71,7 @@ async def test_send_message_includes_context( signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness ) -> None: """The context defaults to the protocol string and flows on the wire.""" - msg = await signalwire_relay_client.send_message( + await signalwire_relay_client.send_message( to_number="+15551112222", from_number="+15553334444", body="hi", diff --git a/tests/unit/relay/test_outbound_call_mock.py b/tests/unit/relay/test_outbound_call_mock.py index 3552ecd0..5b2d32fd 100644 --- a/tests/unit/relay/test_outbound_call_mock.py +++ b/tests/unit/relay/test_outbound_call_mock.py @@ -14,6 +14,7 @@ from __future__ import annotations import asyncio +import contextlib import re import uuid from typing import Any @@ -37,7 +38,9 @@ # --------------------------------------------------------------------------- -def _phone_device(to: str = "+15551112222", frm: str = "+15553334444") -> dict[str, Any]: +def _phone_device( + to: str = "+15551112222", frm: str = "+15553334444" +) -> dict[str, Any]: return {"type": "phone", "params": {"to_number": to, "from_number": frm}} @@ -168,10 +171,8 @@ async def _push_dial_answer() -> None: ) finally: pusher.cancel() - try: + with contextlib.suppress(asyncio.CancelledError): await pusher - except asyncio.CancelledError: - pass assert call.call_id == "auto-tag-winner" # The tag the SDK generated should be a UUID. @@ -231,10 +232,8 @@ async def _push_failure() -> None: ) finally: pusher.cancel() - try: + with contextlib.suppress(asyncio.CancelledError): await pusher - except asyncio.CancelledError: - pass async def test_dial_timeout_when_no_dial_event( @@ -281,7 +280,8 @@ async def test_dial_winner_carries_dial_winner_true( sends = mock_relay.journal_send(event_type="calling.call.dial") assert sends, "no calling.call.dial event was pushed" [final] = [ - e for e in sends + e + for e in sends if (e.frame.get("params", {}).get("params", {}).get("dial_state") == "answered") ] inner = final.frame["params"]["params"] @@ -289,7 +289,9 @@ async def test_dial_winner_carries_dial_winner_true( assert inner["call"]["call_id"] == "WIN-ID" -async def test_dial_losers_get_state_events(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: +async def test_dial_losers_get_state_events( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: """Loser legs receive their own state events ending in ``ended``.""" mock_relay.arm_dial( tag="t-losers", @@ -365,15 +367,12 @@ async def test_dial_devices_serial_two_legs_on_wire( _phone_device(to="+15551110002"), ] ] - await signalwire_relay_client.dial( - devs, tag="t-serial", dial_timeout=5.0 - ) + await signalwire_relay_client.dial(devs, tag="t-serial", dial_timeout=5.0) [entry] = mock_relay.journal_recv(method="calling.dial") assert len(entry.frame["params"]["devices"]) == 1 assert len(entry.frame["params"]["devices"][0]) == 2 assert ( - entry.frame["params"]["devices"][0][0]["params"]["to_number"] - == "+15551110001" + entry.frame["params"]["devices"][0][0]["params"]["to_number"] == "+15551110001" ) @@ -456,7 +455,9 @@ async def test_dialed_call_can_send_subsequent_command( assert end_frames[-1].frame["params"]["call_id"] == "WIN-AFTER" -async def test_dialed_call_can_play(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: +async def test_dialed_call_can_play( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: """A dialed (outbound) call can issue calling.play via the same Call object.""" mock_relay.arm_dial( tag="t-play", @@ -481,7 +482,9 @@ async def test_dialed_call_can_play(signalwire_relay_client: RelayClient, mock_r # --------------------------------------------------------------------------- -async def test_dial_preserves_explicit_tag(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: +async def test_dial_preserves_explicit_tag( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: """An explicit tag flows verbatim into the SDK's Call.tag.""" mock_relay.arm_dial( tag="my-very-explicit-tag-99", @@ -503,7 +506,9 @@ async def test_dial_preserves_explicit_tag(signalwire_relay_client: RelayClient, # --------------------------------------------------------------------------- -async def test_dial_uses_jsonrpc_2_0(signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness) -> None: +async def test_dial_uses_jsonrpc_2_0( + signalwire_relay_client: RelayClient, mock_relay: _MockRelayHarness +) -> None: """The dial frame on the wire is JSON-RPC 2.0 with id+method+params.""" mock_relay.arm_dial( tag="t-rpc", diff --git a/tests/unit/rest/conftest.py b/tests/unit/rest/conftest.py index dd4e1790..a514394f 100644 --- a/tests/unit/rest/conftest.py +++ b/tests/unit/rest/conftest.py @@ -150,7 +150,7 @@ def client(mock_session: MagicMock) -> RestClient: """A RestClient backed by a mock session.""" return RestClient( project="test-project-id", - token="test-token", # noqa: S106 (test credential placeholder) + token="test-token", host="test.signalwire.com", ) diff --git a/tests/unit/rest/fabric_generated_test.py b/tests/unit/rest/fabric_generated_test.py index c0dcc9b1..75486ae8 100644 --- a/tests/unit/rest/fabric_generated_test.py +++ b/tests/unit/rest/fabric_generated_test.py @@ -656,7 +656,7 @@ def test_cxml_webhooks_update_error( def test_freeswitch_connectors_create( self, signalwire_client: RestClient, mock: _MockHarness ) -> None: - signalwire_client.fabric.freeswitch_connectors.create(name="x", token="x") # noqa: S106 + signalwire_client.fabric.freeswitch_connectors.create(name="x", token="x") last = mock.last_request() assert last.method == "POST" assert last.matched_route == "fabric.create_freeswitch_connector" @@ -666,7 +666,7 @@ def test_freeswitch_connectors_create_error( ) -> None: mock.push_scenario("fabric.create_freeswitch_connector", 500, {"error": "x"}) with pytest.raises(SignalWireRestError) as exc: - signalwire_client.fabric.freeswitch_connectors.create(name="x", token="x") # noqa: S106 + signalwire_client.fabric.freeswitch_connectors.create(name="x", token="x") assert exc.value.status_code == 500 def test_freeswitch_connectors_delete( @@ -1198,7 +1198,7 @@ def test_subscribers_create_sip_endpoint( ) -> None: signalwire_client.fabric.subscribers.create_sip_endpoint( "test-id", username="x", password="x" - ) # noqa: S106 + ) last = mock.last_request() assert last.method == "POST" assert last.matched_route == "fabric.create_subscriber_sip_endpoint" @@ -1210,7 +1210,7 @@ def test_subscribers_create_sip_endpoint_error( with pytest.raises(SignalWireRestError) as exc: signalwire_client.fabric.subscribers.create_sip_endpoint( "test-id", username="x", password="x" - ) # noqa: S106 + ) assert exc.value.status_code == 500 def test_subscribers_delete( @@ -1556,7 +1556,7 @@ def test_swml_webhooks_update_error( def test_tokens_create_embed_token( self, signalwire_client: RestClient, mock: _MockHarness ) -> None: - signalwire_client.fabric.tokens.create_embed_token(token="x") # noqa: S106 + signalwire_client.fabric.tokens.create_embed_token(token="x") last = mock.last_request() assert last.method == "POST" assert last.matched_route == "fabric.create_embeds_token" @@ -1566,7 +1566,7 @@ def test_tokens_create_embed_token_error( ) -> None: mock.push_scenario("fabric.create_embeds_token", 500, {"error": "x"}) with pytest.raises(SignalWireRestError) as exc: - signalwire_client.fabric.tokens.create_embed_token(token="x") # noqa: S106 + signalwire_client.fabric.tokens.create_embed_token(token="x") assert exc.value.status_code == 500 def test_tokens_create_guest_token( @@ -1620,7 +1620,7 @@ def test_tokens_create_subscriber_token_error( def test_tokens_refresh_subscriber_token( self, signalwire_client: RestClient, mock: _MockHarness ) -> None: - signalwire_client.fabric.tokens.refresh_subscriber_token(refresh_token="x") # noqa: S106 + signalwire_client.fabric.tokens.refresh_subscriber_token(refresh_token="x") last = mock.last_request() assert last.method == "POST" assert last.matched_route == "fabric.refresh_subscriber_token" @@ -1630,5 +1630,5 @@ def test_tokens_refresh_subscriber_token_error( ) -> None: mock.push_scenario("fabric.refresh_subscriber_token", 500, {"error": "x"}) with pytest.raises(SignalWireRestError) as exc: - signalwire_client.fabric.tokens.refresh_subscriber_token(refresh_token="x") # noqa: S106 + signalwire_client.fabric.tokens.refresh_subscriber_token(refresh_token="x") assert exc.value.status_code == 500 diff --git a/tests/unit/rest/mfa_generated_test.py b/tests/unit/rest/mfa_generated_test.py index 9ac38e50..af3927da 100644 --- a/tests/unit/rest/mfa_generated_test.py +++ b/tests/unit/rest/mfa_generated_test.py @@ -54,7 +54,7 @@ def test_mfa_sms_error( def test_mfa_verify( self, signalwire_client: RestClient, mock: _MockHarness ) -> None: - signalwire_client.mfa.verify("test-id", token="x") # noqa: S106 + signalwire_client.mfa.verify("test-id", token="x") last = mock.last_request() assert last.method == "POST" assert last.matched_route == "relay-rest.verify_mfa_token" @@ -64,5 +64,5 @@ def test_mfa_verify_error( ) -> None: mock.push_scenario("relay-rest.verify_mfa_token", 500, {"error": "x"}) with pytest.raises(SignalWireRestError) as exc: - signalwire_client.mfa.verify("test-id", token="x") # noqa: S106 + signalwire_client.mfa.verify("test-id", token="x") assert exc.value.status_code == 500 diff --git a/tests/unit/rest/test_base.py b/tests/unit/rest/test_base.py index f7fe1efa..fbe1ed22 100644 --- a/tests/unit/rest/test_base.py +++ b/tests/unit/rest/test_base.py @@ -23,8 +23,10 @@ def test_200_with_non_json_body_raises_typed_error( ) -> None: resp = MockResponse(200, None, content=b"oops") resp.text = "oops" + def _raise_json() -> object: raise ValueError("Expecting value: line 1 column 1 (char 0)") + resp.json = _raise_json # type: ignore[method-assign] mock_session.request.return_value = resp with pytest.raises(SignalWireRestError) as exc_info: @@ -41,19 +43,25 @@ def test_request_id_and_headers_on_http_error( self, http: HttpClient, mock_session: MagicMock ) -> None: resp = MockResponse(500, {"error": "boom"}) - resp.headers = {"X-Request-Id": "req-abc-123", "Content-Type": "application/json"} + resp.headers = { + "X-Request-Id": "req-abc-123", + "Content-Type": "application/json", + } mock_session.request.return_value = resp with pytest.raises(SignalWireRestError) as exc_info: http.get("/api/x") err = exc_info.value assert err.request_id == "req-abc-123" - assert err.headers is not None and err.headers.get("X-Request-Id") == "req-abc-123" + assert ( + err.headers is not None and err.headers.get("X-Request-Id") == "req-abc-123" + ) assert "req-abc-123" in str(err) def test_transport_error_has_no_headers( self, http: HttpClient, mock_session: MagicMock ) -> None: import requests + mock_session.request.side_effect = requests.ConnectionError("refused") with pytest.raises(SignalWireRestTransportError) as exc_info: http.get("/api/x") @@ -66,16 +74,26 @@ class TestBaseUrlScheme: https://. Lets a shipped example run verbatim against the local mock without a separate URL knob. Pure _base_url construction — no transport mocked.""" - @pytest.mark.parametrize("host", [ - "127.0.0.1:8790", "127.0.0.1", "localhost:3000", "localhost", - ]) + @pytest.mark.parametrize( + "host", + [ + "127.0.0.1:8790", + "127.0.0.1", + "localhost:3000", + "localhost", + ], + ) def test_loopback_host_uses_http(self, host: str) -> None: c = HttpClient("proj", "tok", host) assert c._base_url == f"http://{host}", c._base_url - @pytest.mark.parametrize("host", [ - "example.signalwire.com", "myspace.signalwire.com", - ]) + @pytest.mark.parametrize( + "host", + [ + "example.signalwire.com", + "myspace.signalwire.com", + ], + ) def test_real_space_uses_https(self, host: str) -> None: c = HttpClient("proj", "tok", host) assert c._base_url == f"https://{host}", c._base_url @@ -100,8 +118,11 @@ def test_get(self, http: HttpClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"data": [1, 2]}) result = http.get("/api/test", params={"page": 1}) mock_session.request.assert_called_once_with( - "GET", "https://test.signalwire.com/api/test", - json=None, params={"page": 1}, timeout=30.0, + "GET", + "https://test.signalwire.com/api/test", + json=None, + params={"page": 1}, + timeout=30.0, ) assert result == {"data": [1, 2]} @@ -109,8 +130,11 @@ def test_post(self, http: HttpClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(201, {"id": "abc"}) result = http.post("/api/test", body={"name": "x"}) mock_session.request.assert_called_once_with( - "POST", "https://test.signalwire.com/api/test", - json={"name": "x"}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/test", + json={"name": "x"}, + params=None, + timeout=30.0, ) assert result == {"id": "abc"} @@ -118,30 +142,42 @@ def test_put(self, http: HttpClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"ok": True}) result = http.put("/api/test/1", body={"name": "y"}) mock_session.request.assert_called_once_with( - "PUT", "https://test.signalwire.com/api/test/1", - json={"name": "y"}, params=None, timeout=30.0, + "PUT", + "https://test.signalwire.com/api/test/1", + json={"name": "y"}, + params=None, + timeout=30.0, ) + assert result == {"ok": True} def test_patch(self, http: HttpClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"ok": True}) http.patch("/api/test/1", body={"name": "z"}) mock_session.request.assert_called_once_with( - "PATCH", "https://test.signalwire.com/api/test/1", - json={"name": "z"}, params=None, timeout=30.0, + "PATCH", + "https://test.signalwire.com/api/test/1", + json={"name": "z"}, + params=None, + timeout=30.0, ) def test_delete(self, http: HttpClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(204, None, content=b"") result = http.delete("/api/test/1") mock_session.request.assert_called_once_with( - "DELETE", "https://test.signalwire.com/api/test/1", - json=None, params=None, timeout=30.0, + "DELETE", + "https://test.signalwire.com/api/test/1", + json=None, + params=None, + timeout=30.0, ) assert result == {} def test_error_raises(self, http: HttpClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse( - 404, {"error": "not found"}, content=b'{"error":"not found"}', + 404, + {"error": "not found"}, + content=b'{"error":"not found"}', ) with pytest.raises(SignalWireRestError) as exc_info: http.get("/api/missing") @@ -187,35 +223,50 @@ def test_list(self, http: HttpClient, mock_session: MagicMock) -> None: res: CrudResource[Any, Any, Any, Any] = CrudResource(http, "/api/items") result = res.list(page=1) mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/items", - json=None, params={"page": 1}, timeout=30.0, + "GET", + "https://test.signalwire.com/api/items", + json=None, + params={"page": 1}, + timeout=30.0, ) + assert result == {"data": []} def test_create(self, http: HttpClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(201, {"id": "new"}) res: CrudResource[Any, Any, Any, Any] = CrudResource(http, "/api/items") result = res.create(name="test") mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/items", - json={"name": "test"}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/items", + json={"name": "test"}, + params=None, + timeout=30.0, ) + assert result == {"id": "new"} def test_get(self, http: HttpClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"id": "abc"}) res: CrudResource[Any, Any, Any, Any] = CrudResource(http, "/api/items") result = res.get("abc") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/items/abc", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/items/abc", + json=None, + params=None, + timeout=30.0, ) + assert result == {"id": "abc"} def test_update_patch(self, http: HttpClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"ok": True}) res: CrudResource[Any, Any, Any, Any] = CrudResource(http, "/api/items") res.update("abc", name="updated") mock_session.request.assert_called_with( - "PATCH", "https://test.signalwire.com/api/items/abc", - json={"name": "updated"}, params=None, timeout=30.0, + "PATCH", + "https://test.signalwire.com/api/items/abc", + json={"name": "updated"}, + params=None, + timeout=30.0, ) def test_update_put(self, http: HttpClient, mock_session: MagicMock) -> None: @@ -227,8 +278,11 @@ class PutResource(CrudResource[Any, Any, Any, Any]): res = PutResource(http, "/api/items") res.update("abc", name="updated") mock_session.request.assert_called_with( - "PUT", "https://test.signalwire.com/api/items/abc", - json={"name": "updated"}, params=None, timeout=30.0, + "PUT", + "https://test.signalwire.com/api/items/abc", + json={"name": "updated"}, + params=None, + timeout=30.0, ) def test_delete(self, http: HttpClient, mock_session: MagicMock) -> None: @@ -236,17 +290,25 @@ def test_delete(self, http: HttpClient, mock_session: MagicMock) -> None: res: CrudResource[Any, Any, Any, Any] = CrudResource(http, "/api/items") res.delete("abc") mock_session.request.assert_called_with( - "DELETE", "https://test.signalwire.com/api/items/abc", - json=None, params=None, timeout=30.0, + "DELETE", + "https://test.signalwire.com/api/items/abc", + json=None, + params=None, + timeout=30.0, ) class TestCrudWithAddresses: def test_list_addresses(self, http: HttpClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"data": []}) - res: CrudWithAddresses[Any, Any, Any, Any] = CrudWithAddresses(http, "/api/fabric/resources/ai_agents") + res: CrudWithAddresses[Any, Any, Any, Any] = CrudWithAddresses( + http, "/api/fabric/resources/ai_agents" + ) res.list_addresses("abc") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/fabric/resources/ai_agents/abc/addresses", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/fabric/resources/ai_agents/abc/addresses", + json=None, + params=None, + timeout=30.0, ) diff --git a/tests/unit/rest/test_calling.py b/tests/unit/rest/test_calling.py index 365505ba..5c4cc7ac 100644 --- a/tests/unit/rest/test_calling.py +++ b/tests/unit/rest/test_calling.py @@ -16,9 +16,13 @@ def test_dial(self, client: RestClient, mock_session: MagicMock) -> None: assert body["params"]["to"] == "+15551234567" assert "id" not in body # dial has no top-level id - def test_play_with_call_id(self, client: RestClient, mock_session: MagicMock) -> None: + def test_play_with_call_id( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(200, {}) - client.calling.play("call-123", play=[{"type": "tts", "params": {"text": "hello"}}]) + client.calling.play( + "call-123", play=[{"type": "tts", "params": {"text": "hello"}}] + ) call_args = mock_session.request.call_args body = call_args[1]["json"] assert body["command"] == "calling.play" @@ -49,19 +53,43 @@ def test_ai_message(self, client: RestClient, mock_session: MagicMock) -> None: def test_all_methods_exist(self, client: RestClient) -> None: methods = [ - "dial", "update", "end", "transfer", "disconnect", - "play", "play_pause", "play_resume", "play_stop", "play_volume", - "record", "record_pause", "record_resume", "record_stop", - "collect", "collect_stop", "collect_start_input_timers", - "detect", "detect_stop", - "tap", "tap_stop", - "stream", "stream_stop", - "denoise", "denoise_stop", - "transcribe", "transcribe_stop", - "ai_message", "ai_hold", "ai_unhold", "ai_stop", - "live_transcribe", "live_translate", - "send_fax_stop", "receive_fax_stop", - "refer", "user_event", + "dial", + "update", + "end", + "transfer", + "disconnect", + "play", + "play_pause", + "play_resume", + "play_stop", + "play_volume", + "record", + "record_pause", + "record_resume", + "record_stop", + "collect", + "collect_stop", + "collect_start_input_timers", + "detect", + "detect_stop", + "tap", + "tap_stop", + "stream", + "stream_stop", + "denoise", + "denoise_stop", + "transcribe", + "transcribe_stop", + "ai_message", + "ai_hold", + "ai_unhold", + "ai_stop", + "live_transcribe", + "live_translate", + "send_fax_stop", + "receive_fax_stop", + "refer", + "user_event", ] for method in methods: assert hasattr(client.calling, method), f"Missing calling method: {method}" diff --git a/tests/unit/rest/test_calling_mock.py b/tests/unit/rest/test_calling_mock.py index c6952268..54fa5ae3 100644 --- a/tests/unit/rest/test_calling_mock.py +++ b/tests/unit/rest/test_calling_mock.py @@ -25,7 +25,9 @@ class TestCallingLifecycle: - def test_dial_with_codecs_array(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_dial_with_codecs_array( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.dial( url="https://example.com/swml", to="+15551234567", @@ -40,11 +42,16 @@ def test_dial_with_codecs_array(self, signalwire_client: RestClient, mock: _Mock assert last.body.get("command") == "dial" assert "id" not in last.body assert last.body.get("params", {}).get("codecs") == [ - "OPUS", "G729", "VP8", "PCMA", + "OPUS", + "G729", + "VP8", + "PCMA", ] assert last.body.get("params", {}).get("to") == "+15551234567" - def test_dial_with_codecs_string(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_dial_with_codecs_string( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.dial( url="https://example.com/swml", to="+15551234567", @@ -71,7 +78,8 @@ def test_update(self, signalwire_client: RestClient, mock: _MockHarness) -> None def test_transfer(self, signalwire_client: RestClient, mock: _MockHarness) -> None: body = signalwire_client.calling.transfer( - "call-123", dest="sip:destination@example.com", + "call-123", + dest="sip:destination@example.com", ) assert isinstance(body, dict) assert "id" in body @@ -82,7 +90,9 @@ def test_transfer(self, signalwire_client: RestClient, mock: _MockHarness) -> No assert last.body.get("id") == "call-123" assert last.body.get("params", {}).get("dest") == "sip:destination@example.com" - def test_disconnect(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_disconnect( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.disconnect("call-456") assert isinstance(body, dict) assert "id" in body @@ -99,7 +109,9 @@ def test_disconnect(self, signalwire_client: RestClient, mock: _MockHarness) -> class TestCallingPlay: - def test_play_pause(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_play_pause( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.play_pause("call-1", control_id="ctrl-1") assert isinstance(body, dict) assert "id" in body @@ -110,7 +122,9 @@ def test_play_pause(self, signalwire_client: RestClient, mock: _MockHarness) -> assert last.body.get("id") == "call-1" assert last.body.get("params", {}).get("control_id") == "ctrl-1" - def test_play_resume(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_play_resume( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.play_resume("call-1", control_id="ctrl-1") assert isinstance(body, dict) assert "id" in body @@ -132,9 +146,13 @@ def test_play_stop(self, signalwire_client: RestClient, mock: _MockHarness) -> N assert last.body.get("id") == "call-1" assert last.body.get("params", {}).get("control_id") == "ctrl-1" - def test_play_volume(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_play_volume( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.play_volume( - "call-1", control_id="ctrl-1", volume=2.5, + "call-1", + control_id="ctrl-1", + volume=2.5, ) assert isinstance(body, dict) assert "id" in body @@ -163,7 +181,9 @@ def test_record(self, signalwire_client: RestClient, mock: _MockHarness) -> None assert last.body.get("id") == "call-1" assert last.body.get("params", {}).get("audio") == {"format": "mp3"} - def test_record_pause(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_record_pause( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.record_pause("call-1", control_id="rec-1") assert isinstance(body, dict) assert "id" in body @@ -174,7 +194,9 @@ def test_record_pause(self, signalwire_client: RestClient, mock: _MockHarness) - assert last.body.get("id") == "call-1" assert last.body.get("params", {}).get("control_id") == "rec-1" - def test_record_resume(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_record_resume( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.record_resume("call-1", control_id="rec-1") assert isinstance(body, dict) assert "id" in body @@ -194,7 +216,9 @@ def test_record_resume(self, signalwire_client: RestClient, mock: _MockHarness) class TestCallingCollect: def test_collect(self, signalwire_client: RestClient, mock: _MockHarness) -> None: body = signalwire_client.calling.collect( - "call-1", initial_timeout=5, digits={"max": 4}, + "call-1", + initial_timeout=5, + digits={"max": 4}, ) assert isinstance(body, dict) assert "id" in body @@ -205,7 +229,9 @@ def test_collect(self, signalwire_client: RestClient, mock: _MockHarness) -> Non assert last.body.get("id") == "call-1" assert last.body.get("params", {}).get("initial_timeout") == 5 - def test_collect_stop(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_collect_stop( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.collect_stop("call-1", control_id="col-1") assert isinstance(body, dict) assert "id" in body @@ -216,9 +242,12 @@ def test_collect_stop(self, signalwire_client: RestClient, mock: _MockHarness) - assert last.body.get("id") == "call-1" assert last.body.get("params", {}).get("control_id") == "col-1" - def test_collect_start_input_timers(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_collect_start_input_timers( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.collect_start_input_timers( - "call-1", control_id="col-1", + "call-1", + control_id="col-1", ) assert isinstance(body, dict) assert "id" in body @@ -238,7 +267,8 @@ def test_collect_start_input_timers(self, signalwire_client: RestClient, mock: _ class TestCallingDetect: def test_detect(self, signalwire_client: RestClient, mock: _MockHarness) -> None: body = signalwire_client.calling.detect( - "call-1", detect={"type": "machine", "params": {}}, + "call-1", + detect={"type": "machine", "params": {}}, ) assert isinstance(body, dict) assert "id" in body @@ -249,7 +279,9 @@ def test_detect(self, signalwire_client: RestClient, mock: _MockHarness) -> None assert last.body.get("id") == "call-1" assert last.body.get("params", {}).get("detect", {}).get("type") == "machine" - def test_detect_stop(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_detect_stop( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.detect_stop("call-1", control_id="det-1") assert isinstance(body, dict) assert "id" in body @@ -264,7 +296,9 @@ def test_detect_stop(self, signalwire_client: RestClient, mock: _MockHarness) -> class TestCallingTap: def test_tap(self, signalwire_client: RestClient, mock: _MockHarness) -> None: body = signalwire_client.calling.tap( - "call-1", tap={"type": "audio"}, device={"type": "rtp"}, + "call-1", + tap={"type": "audio"}, + device={"type": "rtp"}, ) assert isinstance(body, dict) assert "id" in body @@ -290,7 +324,8 @@ def test_tap_stop(self, signalwire_client: RestClient, mock: _MockHarness) -> No class TestCallingStream: def test_stream(self, signalwire_client: RestClient, mock: _MockHarness) -> None: body = signalwire_client.calling.stream( - "call-1", url="wss://example.com/audio", + "call-1", + url="wss://example.com/audio", ) assert isinstance(body, dict) assert "id" in body @@ -301,7 +336,9 @@ def test_stream(self, signalwire_client: RestClient, mock: _MockHarness) -> None assert last.body.get("id") == "call-1" assert last.body.get("params", {}).get("url") == "wss://example.com/audio" - def test_stream_stop(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_stream_stop( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.stream_stop("call-1", control_id="stream-1") assert isinstance(body, dict) assert "id" in body @@ -324,7 +361,9 @@ def test_denoise(self, signalwire_client: RestClient, mock: _MockHarness) -> Non assert last.body.get("command") == "calling.denoise" assert last.body.get("id") == "call-1" - def test_denoise_stop(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_denoise_stop( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.denoise_stop("call-1") assert isinstance(body, dict) assert "id" in body @@ -336,9 +375,13 @@ def test_denoise_stop(self, signalwire_client: RestClient, mock: _MockHarness) - class TestCallingTranscribe: - def test_transcribe(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_transcribe( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.transcribe( - "call-1", control_id="tr-1", status_url="https://example.com/status", + "call-1", + control_id="tr-1", + status_url="https://example.com/status", ) assert isinstance(body, dict) assert "id" in body @@ -349,7 +392,9 @@ def test_transcribe(self, signalwire_client: RestClient, mock: _MockHarness) -> assert last.body.get("id") == "call-1" assert last.body.get("params", {}).get("control_id") == "tr-1" - def test_transcribe_stop(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_transcribe_stop( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.transcribe_stop("call-1", control_id="tr-1") assert isinstance(body, dict) assert "id" in body @@ -405,7 +450,9 @@ def test_ai_stop(self, signalwire_client: RestClient, mock: _MockHarness) -> Non class TestCallingLive: - def test_live_transcribe(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_live_transcribe( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.live_transcribe( "call-1", action={"start": {"lang": "en-US", "direction": ["local-caller"]}}, @@ -422,7 +469,9 @@ def test_live_transcribe(self, signalwire_client: RestClient, mock: _MockHarness == "en-US" ) - def test_live_translate(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_live_translate( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.live_translate( "call-1", action={ @@ -451,7 +500,9 @@ def test_live_translate(self, signalwire_client: RestClient, mock: _MockHarness) class TestCallingFax: - def test_send_fax_stop(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_send_fax_stop( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.send_fax_stop("call-1", control_id="fax-1") assert isinstance(body, dict) assert "id" in body @@ -462,7 +513,9 @@ def test_send_fax_stop(self, signalwire_client: RestClient, mock: _MockHarness) assert last.body.get("id") == "call-1" assert last.body.get("params", {}).get("control_id") == "fax-1" - def test_receive_fax_stop(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_receive_fax_stop( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.receive_fax_stop("call-1", control_id="fax-2") assert isinstance(body, dict) assert "id" in body @@ -495,9 +548,12 @@ def test_refer(self, signalwire_client: RestClient, mock: _MockHarness) -> None: device = last.body.get("params", {}).get("device", {}) assert device.get("params", {}).get("to") == "sip:other@example.com" - def test_user_event(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_user_event( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.calling.user_event( - "call-1", event={"action": "my-event", "data": {"foo": "bar"}}, + "call-1", + event={"action": "my-event", "data": {"foo": "bar"}}, ) assert isinstance(body, dict) assert "id" in body diff --git a/tests/unit/rest/test_fabric.py b/tests/unit/rest/test_fabric.py index 1808060f..18895592 100644 --- a/tests/unit/rest/test_fabric.py +++ b/tests/unit/rest/test_fabric.py @@ -13,11 +13,16 @@ def test_ai_agents_list(self, client: RestClient, mock_session: MagicMock) -> No mock_session.request.return_value = MockResponse(200, {"data": []}) client.fabric.ai_agents.list() mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/fabric/resources/ai_agents", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/fabric/resources/ai_agents", + json=None, + params=None, + timeout=30.0, ) - def test_ai_agents_create(self, client: RestClient, mock_session: MagicMock) -> None: + def test_ai_agents_create( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(201, {"id": "new"}) client.fabric.ai_agents.create( name="Support", @@ -25,33 +30,48 @@ def test_ai_agents_create(self, client: RestClient, mock_session: MagicMock) -> agent_id="a1", ) mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/fabric/resources/ai_agents", + "POST", + "https://test.signalwire.com/api/fabric/resources/ai_agents", json={"name": "Support", "prompt": "You are helpful", "agent_id": "a1"}, - params=None, timeout=30.0, + params=None, + timeout=30.0, ) - def test_ai_agents_update_uses_patch(self, client: RestClient, mock_session: MagicMock) -> None: + def test_ai_agents_update_uses_patch( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(200, {}) client.fabric.ai_agents.update("id-1", name="Updated") mock_session.request.assert_called_with( - "PATCH", "https://test.signalwire.com/api/fabric/resources/ai_agents/id-1", - json={"name": "Updated"}, params=None, timeout=30.0, + "PATCH", + "https://test.signalwire.com/api/fabric/resources/ai_agents/id-1", + json={"name": "Updated"}, + params=None, + timeout=30.0, ) - def test_swml_scripts_update_uses_put(self, client: RestClient, mock_session: MagicMock) -> None: + def test_swml_scripts_update_uses_put( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(200, {}) client.fabric.swml_scripts.update("id-1", contents="{}") mock_session.request.assert_called_with( - "PUT", "https://test.signalwire.com/api/fabric/resources/swml_scripts/id-1", - json={"contents": "{}"}, params=None, timeout=30.0, + "PUT", + "https://test.signalwire.com/api/fabric/resources/swml_scripts/id-1", + json={"contents": "{}"}, + params=None, + timeout=30.0, ) def test_list_addresses(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"data": []}) client.fabric.ai_agents.list_addresses("id-1") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/fabric/resources/ai_agents/id-1/addresses", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/fabric/resources/ai_agents/id-1/addresses", + json=None, + params=None, + timeout=30.0, ) @@ -60,41 +80,59 @@ def test_list_versions(self, client: RestClient, mock_session: MagicMock) -> Non mock_session.request.return_value = MockResponse(200, {"data": []}) client.fabric.call_flows.list_versions("cf-1") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/fabric/resources/call_flow/cf-1/versions", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/fabric/resources/call_flow/cf-1/versions", + json=None, + params=None, + timeout=30.0, ) def test_deploy_version(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {}) client.fabric.call_flows.deploy_version("cf-1", {"document_version": 2}) mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/fabric/resources/call_flow/cf-1/versions", - json={"document_version": 2}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/fabric/resources/call_flow/cf-1/versions", + json={"document_version": 2}, + params=None, + timeout=30.0, ) class TestFabricSubscribers: - def test_list_sip_endpoints(self, client: RestClient, mock_session: MagicMock) -> None: + def test_list_sip_endpoints( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(200, {"data": []}) client.fabric.subscribers.list_sip_endpoints("sub-1") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/fabric/resources/subscribers/sub-1/sip_endpoints", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/fabric/resources/subscribers/sub-1/sip_endpoints", + json=None, + params=None, + timeout=30.0, ) - def test_create_sip_endpoint(self, client: RestClient, mock_session: MagicMock) -> None: + def test_create_sip_endpoint( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(201, {"id": "ep-1"}) client.fabric.subscribers.create_sip_endpoint( - "sub-1", username="user1", password="s3cret" # noqa: S106 + "sub-1", username="user1", password="s3cret" ) mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/fabric/resources/subscribers/sub-1/sip_endpoints", - json={"username": "user1", "password": "s3cret"}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/fabric/resources/subscribers/sub-1/sip_endpoints", + json={"username": "user1", "password": "s3cret"}, + params=None, + timeout=30.0, ) class TestGenericResources: - def test_assign_phone_route_posts(self, client: RestClient, mock_session: MagicMock) -> None: + def test_assign_phone_route_posts( + self, client: RestClient, mock_session: MagicMock + ) -> None: """assign_phone_route posts the phone_route_id and handler to the resource. The spec's PhoneRouteAssignRequest requires both phone_route_id and a @@ -106,8 +144,11 @@ def test_assign_phone_route_posts(self, client: RestClient, mock_session: MagicM "res-1", phone_route_id="pr-1", handler="calling" ) mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/fabric/resources/res-1/phone_routes", - json={"phone_route_id": "pr-1", "handler": "calling"}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/fabric/resources/res-1/phone_routes", + json={"phone_route_id": "pr-1", "handler": "calling"}, + params=None, + timeout=30.0, ) @@ -117,21 +158,33 @@ class TestWebhooks: direct create is a normal operation (these SDKs are pre-release — no deprecation). """ - def test_swml_webhooks_create_no_warning(self, client: RestClient, mock_session: MagicMock, recwarn: pytest.WarningsRecorder) -> None: + def test_swml_webhooks_create_no_warning( + self, + client: RestClient, + mock_session: MagicMock, + recwarn: pytest.WarningsRecorder, + ) -> None: mock_session.request.return_value = MockResponse(201, {"id": "sw-1"}) client.fabric.swml_webhooks.create( primary_request_url="https://example.com/swml", ) assert not [w for w in recwarn if issubclass(w.category, DeprecationWarning)] - def test_cxml_webhooks_create_no_warning(self, client: RestClient, mock_session: MagicMock, recwarn: pytest.WarningsRecorder) -> None: + def test_cxml_webhooks_create_no_warning( + self, + client: RestClient, + mock_session: MagicMock, + recwarn: pytest.WarningsRecorder, + ) -> None: mock_session.request.return_value = MockResponse(201, {"id": "cw-1"}) client.fabric.cxml_webhooks.create( primary_request_url="https://example.com/voice.xml", ) assert not [w for w in recwarn if issubclass(w.category, DeprecationWarning)] - def test_webhooks_read_update_delete_work_without_warning(self, client: RestClient, mock_session: MagicMock) -> None: + def test_webhooks_read_update_delete_work_without_warning( + self, client: RestClient, mock_session: MagicMock + ) -> None: """Webhooks are plain CRUD — list/get/update/delete/create all run without emitting any DeprecationWarning. We assert the underlying HTTP transport actually got called for each operation (proving the methods aren't no-ops).""" @@ -154,20 +207,30 @@ def test_webhooks_read_update_delete_work_without_warning(self, client: RestClie class TestFabricTokens: - def test_create_subscriber_token(self, client: RestClient, mock_session: MagicMock) -> None: + def test_create_subscriber_token( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(200, {"token": "abc"}) client.fabric.tokens.create_subscriber_token(reference="user@example.com") mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/fabric/subscribers/tokens", - json={"reference": "user@example.com"}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/fabric/subscribers/tokens", + json={"reference": "user@example.com"}, + params=None, + timeout=30.0, ) - def test_create_guest_token(self, client: RestClient, mock_session: MagicMock) -> None: + def test_create_guest_token( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(200, {"token": "abc"}) client.fabric.tokens.create_guest_token(allowed_addresses=["addr-1"]) mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/fabric/guests/tokens", - json={"allowed_addresses": ["addr-1"]}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/fabric/guests/tokens", + json={"allowed_addresses": ["addr-1"]}, + params=None, + timeout=30.0, ) @@ -176,10 +239,19 @@ class TestAllFabricResources: def test_resource_types_exist(self, client: RestClient) -> None: resources = [ - "swml_scripts", "swml_webhooks", "ai_agents", "relay_applications", - "call_flows", "conference_rooms", "freeswitch_connectors", - "subscribers", "sip_endpoints", "sip_gateways", - "cxml_scripts", "cxml_webhooks", "cxml_applications", + "swml_scripts", + "swml_webhooks", + "ai_agents", + "relay_applications", + "call_flows", + "conference_rooms", + "freeswitch_connectors", + "subscribers", + "sip_endpoints", + "sip_gateways", + "cxml_scripts", + "cxml_webhooks", + "cxml_applications", ] for name in resources: assert hasattr(client.fabric, name), f"Missing fabric resource: {name}" diff --git a/tests/unit/rest/test_fabric_mock.py b/tests/unit/rest/test_fabric_mock.py index 90ad6b6d..d9e98960 100644 --- a/tests/unit/rest/test_fabric_mock.py +++ b/tests/unit/rest/test_fabric_mock.py @@ -28,7 +28,9 @@ class TestFabricAddresses: """``client.fabric.addresses.*`` — list and get only.""" - def test_list_returns_data_collection(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_returns_data_collection( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.fabric.addresses.list() assert isinstance(body, dict), f"expected dict, got {type(body).__name__}" # Fabric addresses list returns 'data' arrays. @@ -42,7 +44,9 @@ def test_list_returns_data_collection(self, signalwire_client: RestClient, mock: f"expected fabric.list_fabric_addresses, got {last.matched_route!r}" ) - def test_get_uses_address_id(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_uses_address_id( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.fabric.addresses.get("addr-9001") assert isinstance(body, dict) # The retrieve endpoint synthesises a single address resource. @@ -68,14 +72,14 @@ class TestCxmlApplicationsCreate: ``create`` stub; the generated surface omits the method entirely. """ - def test_create_method_is_absent(self, signalwire_client: RestClient, mock: _MockHarness) -> None: - assert not hasattr( - signalwire_client.fabric.cxml_applications, "create" - ), "cxml_applications has no create route in the spec; create must not exist" - # Nothing should have hit the wire. - assert mock.journal == [], ( - f"expected no journal entries, got {mock.journal}" + def test_create_method_is_absent( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + assert not hasattr(signalwire_client.fabric.cxml_applications, "create"), ( + "cxml_applications has no create route in the spec; create must not exist" ) + # Nothing should have hit the wire. + assert mock.journal == [], f"expected no journal entries, got {mock.journal}" # --------------------------------------------------------------------------- @@ -90,7 +94,9 @@ class TestCallFlowsAddresses: paths because that's what the API spec uses. """ - def test_list_addresses_uses_singular_path(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_addresses_uses_singular_path( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.fabric.call_flows.list_addresses("cf-1") assert isinstance(body, dict) assert "data" in body and isinstance(body["data"], list) @@ -99,9 +105,7 @@ def test_list_addresses_uses_singular_path(self, signalwire_client: RestClient, assert last.method == "GET" # singular 'call_flow' (NOT 'call_flows') in the addresses sub-path. assert last.path == "/api/fabric/resources/call_flow/cf-1/addresses" - assert last.matched_route is not None, ( - "spec gap: call-flow addresses sub-path" - ) + assert last.matched_route is not None, "spec gap: call-flow addresses sub-path" # --------------------------------------------------------------------------- @@ -113,7 +117,9 @@ class TestConferenceRoomsAddresses: """``conference_rooms.list_addresses`` rewrites ``/conference_rooms`` to ``/conference_room`` for sub-collections, mirroring call_flows.""" - def test_list_addresses_uses_singular_path(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_addresses_uses_singular_path( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.fabric.conference_rooms.list_addresses("cr-1") assert isinstance(body, dict) assert "data" in body @@ -133,10 +139,10 @@ def test_list_addresses_uses_singular_path(self, signalwire_client: RestClient, class TestSubscribersSipEndpointOps: """``subscribers.{get,update,delete}_sip_endpoint(sub_id, ep_id)``.""" - def test_get_sip_endpoint(self, signalwire_client: RestClient, mock: _MockHarness) -> None: - body = signalwire_client.fabric.subscribers.get_sip_endpoint( - "sub-1", "ep-1" - ) + def test_get_sip_endpoint( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + body = signalwire_client.fabric.subscribers.get_sip_endpoint("sub-1", "ep-1") assert isinstance(body, dict) last = mock.last_request() @@ -146,7 +152,9 @@ def test_get_sip_endpoint(self, signalwire_client: RestClient, mock: _MockHarnes ) assert last.matched_route is not None - def test_update_sip_endpoint_uses_patch(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_update_sip_endpoint_uses_patch( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.fabric.subscribers.update_sip_endpoint( "sub-1", "ep-1", username="renamed" ) @@ -160,10 +168,10 @@ def test_update_sip_endpoint_uses_patch(self, signalwire_client: RestClient, moc assert isinstance(last.body, dict) assert last.body.get("username") == "renamed" - def test_delete_sip_endpoint(self, signalwire_client: RestClient, mock: _MockHarness) -> None: - body = signalwire_client.fabric.subscribers.delete_sip_endpoint( - "sub-1", "ep-1" - ) + def test_delete_sip_endpoint( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + body = signalwire_client.fabric.subscribers.delete_sip_endpoint("sub-1", "ep-1") assert isinstance(body, dict) # SDK normalises 204 to {} last = mock.last_request() @@ -187,7 +195,9 @@ class TestFabricTokens: - ``refresh_subscriber_token`` -> POST /api/fabric/subscribers/tokens/refresh """ - def test_create_invite_token(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_create_invite_token( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.fabric.tokens.create_invite_token( address_id="3fa85f64-5717-4562-b3fc-2c963f66afa6" ) @@ -198,13 +208,13 @@ def test_create_invite_token(self, signalwire_client: RestClient, mock: _MockHar # subscriber/invites uses the singular 'subscriber' path segment. assert last.path == "/api/fabric/subscriber/invites" assert isinstance(last.body, dict) - assert ( - last.body.get("address_id") == "3fa85f64-5717-4562-b3fc-2c963f66afa6" - ) + assert last.body.get("address_id") == "3fa85f64-5717-4562-b3fc-2c963f66afa6" - def test_create_embed_token(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_create_embed_token( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.fabric.tokens.create_embed_token( - token="c2c_7acc0e5e968706a032983cd80cdca219" # noqa: S106 - test fixture value, not a real secret + token="c2c_7acc0e5e968706a032983cd80cdca219" ) assert isinstance(body, dict) @@ -214,9 +224,11 @@ def test_create_embed_token(self, signalwire_client: RestClient, mock: _MockHarn assert isinstance(last.body, dict) assert last.body.get("token") == "c2c_7acc0e5e968706a032983cd80cdca219" - def test_refresh_subscriber_token(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_refresh_subscriber_token( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.fabric.tokens.refresh_subscriber_token( - refresh_token="abc-123" # noqa: S106 - test fixture value, not a real secret + refresh_token="abc-123" ) assert isinstance(body, dict) @@ -236,7 +248,9 @@ class TestGenericResources: """``client.fabric.resources.*`` — list/get/delete/list_addresses across every resource type, plus assign_domain_application.""" - def test_list_returns_data_collection(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_returns_data_collection( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.fabric.resources.list() assert isinstance(body, dict) # /api/fabric/resources returns data array. @@ -247,7 +261,9 @@ def test_list_returns_data_collection(self, signalwire_client: RestClient, mock: assert last.path == "/api/fabric/resources" assert last.matched_route is not None - def test_get_returns_single_resource(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_returns_single_resource( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.fabric.resources.get("res-1") assert isinstance(body, dict) @@ -264,7 +280,9 @@ def test_delete(self, signalwire_client: RestClient, mock: _MockHarness) -> None assert last.path == "/api/fabric/resources/res-2" assert last.matched_route is not None - def test_list_addresses(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_addresses( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.fabric.resources.list_addresses("res-3") assert isinstance(body, dict) assert "data" in body and isinstance(body["data"], list) @@ -273,7 +291,9 @@ def test_list_addresses(self, signalwire_client: RestClient, mock: _MockHarness) assert last.method == "GET" assert last.path == "/api/fabric/resources/res-3/addresses" - def test_assign_domain_application(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_assign_domain_application( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.fabric.resources.assign_domain_application( "res-4", domain_application_id="da-7" ) diff --git a/tests/unit/rest/test_logs_mock.py b/tests/unit/rest/test_logs_mock.py index a5e256e4..05d6368c 100644 --- a/tests/unit/rest/test_logs_mock.py +++ b/tests/unit/rest/test_logs_mock.py @@ -25,7 +25,9 @@ class TestMessageLogs: """``client.logs.messages.*`` — list and per-id get.""" - def test_list_returns_dict(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_returns_dict( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.logs.messages.list() assert isinstance(body, dict), f"expected dict, got {type(body).__name__}" @@ -36,7 +38,9 @@ def test_list_returns_dict(self, signalwire_client: RestClient, mock: _MockHarne f"expected message.list_message_logs, got {last.matched_route!r}" ) - def test_get_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_uses_id_in_path( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.logs.messages.get("ml-42") assert isinstance(body, dict) # Single-log endpoint returns one resource object, not a collection. @@ -55,7 +59,9 @@ def test_get_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHar class TestVoiceLogs: """``client.logs.voice.*`` — list and per-id get (events covered elsewhere).""" - def test_list_returns_dict(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_returns_dict( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.logs.voice.list() assert isinstance(body, dict) @@ -64,7 +70,9 @@ def test_list_returns_dict(self, signalwire_client: RestClient, mock: _MockHarne assert last.path == "/api/voice/logs" assert last.matched_route == "voice.list_voice_logs" - def test_get_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_uses_id_in_path( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.logs.voice.get("vl-99") assert isinstance(body, dict) @@ -81,7 +89,9 @@ def test_get_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHar class TestFaxLogs: """``client.logs.fax.*`` — list and per-id get.""" - def test_list_returns_dict(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_returns_dict( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.logs.fax.list() assert isinstance(body, dict) @@ -90,7 +100,9 @@ def test_list_returns_dict(self, signalwire_client: RestClient, mock: _MockHarne assert last.path == "/api/fax/logs" assert last.matched_route == "fax.list_fax_logs" - def test_get_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_uses_id_in_path( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.logs.fax.get("fl-7") assert isinstance(body, dict) @@ -107,7 +119,9 @@ def test_get_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHar class TestConferenceLogs: """``client.logs.conferences.list`` — list-only resource.""" - def test_list_returns_dict(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_returns_dict( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.logs.conferences.list() assert isinstance(body, dict) diff --git a/tests/unit/rest/test_namespaces.py b/tests/unit/rest/test_namespaces.py index 56cd68db..d5fbcd7a 100644 --- a/tests/unit/rest/test_namespaces.py +++ b/tests/unit/rest/test_namespaces.py @@ -16,16 +16,22 @@ def test_search(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"data": []}) client.phone_numbers.search(areacode="512") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/relay/rest/phone_numbers/search", - json=None, params={"areacode": "512"}, timeout=30.0, + "GET", + "https://test.signalwire.com/api/relay/rest/phone_numbers/search", + json=None, + params={"areacode": "512"}, + timeout=30.0, ) def test_update_uses_put(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {}) client.phone_numbers.update("pn-1", name="Main") mock_session.request.assert_called_with( - "PUT", "https://test.signalwire.com/api/relay/rest/phone_numbers/pn-1", - json={"name": "Main"}, params=None, timeout=30.0, + "PUT", + "https://test.signalwire.com/api/relay/rest/phone_numbers/pn-1", + json={"name": "Main"}, + params=None, + timeout=30.0, ) @@ -34,16 +40,22 @@ def test_list_members(self, client: RestClient, mock_session: MagicMock) -> None mock_session.request.return_value = MockResponse(200, {"data": []}) client.queues.list_members("q-1") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/relay/rest/queues/q-1/members", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/relay/rest/queues/q-1/members", + json=None, + params=None, + timeout=30.0, ) def test_get_next_member(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {}) client.queues.get_next_member("q-1") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/relay/rest/queues/q-1/members/next", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/relay/rest/queues/q-1/members/next", + json=None, + params=None, + timeout=30.0, ) @@ -52,34 +64,50 @@ def test_add_membership(self, client: RestClient, mock_session: MagicMock) -> No mock_session.request.return_value = MockResponse(201, {}) client.number_groups.add_membership("ng-1", phone_number_id="pn-1") mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/relay/rest/number_groups/ng-1/number_group_memberships", - json={"phone_number_id": "pn-1"}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/relay/rest/number_groups/ng-1/number_group_memberships", + json={"phone_number_id": "pn-1"}, + params=None, + timeout=30.0, ) def test_get_membership(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {}) client.number_groups.get_membership("mem-1") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/relay/rest/number_group_memberships/mem-1", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/relay/rest/number_group_memberships/mem-1", + json=None, + params=None, + timeout=30.0, ) class TestVerifiedCallers: - def test_redial_verification(self, client: RestClient, mock_session: MagicMock) -> None: + def test_redial_verification( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(200, {}) client.verified_callers.redial_verification("vc-1") mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/relay/rest/verified_caller_ids/vc-1/verification", - json=None, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/relay/rest/verified_caller_ids/vc-1/verification", + json=None, + params=None, + timeout=30.0, ) - def test_submit_verification(self, client: RestClient, mock_session: MagicMock) -> None: + def test_submit_verification( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(200, {}) client.verified_callers.submit_verification("vc-1", verification_code="123456") mock_session.request.assert_called_with( - "PUT", "https://test.signalwire.com/api/relay/rest/verified_caller_ids/vc-1/verification", - json={"verification_code": "123456"}, params=None, timeout=30.0, + "PUT", + "https://test.signalwire.com/api/relay/rest/verified_caller_ids/vc-1/verification", + json={"verification_code": "123456"}, + params=None, + timeout=30.0, ) @@ -88,8 +116,11 @@ def test_get(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"sip_uri": "test"}) client.sip_profile.get() mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/relay/rest/sip_profile", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/relay/rest/sip_profile", + json=None, + params=None, + timeout=30.0, ) @@ -98,8 +129,11 @@ def test_phone_number(self, client: RestClient, mock_session: MagicMock) -> None mock_session.request.return_value = MockResponse(200, {}) client.lookup.phone_number("+15551234567", include="carrier") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/relay/rest/lookup/phone_number/+15551234567", - json=None, params={"include": "carrier"}, timeout=30.0, + "GET", + "https://test.signalwire.com/api/relay/rest/lookup/phone_number/+15551234567", + json=None, + params={"include": "carrier"}, + timeout=30.0, ) @@ -108,16 +142,22 @@ def test_sms(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"id": "mfa-1"}) client.mfa.sms(to="+15551234567", from_="+15559876543") mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/relay/rest/mfa/sms", - json={"to": "+15551234567", "from": "+15559876543"}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/relay/rest/mfa/sms", + json={"to": "+15551234567", "from": "+15559876543"}, + params=None, + timeout=30.0, ) def test_verify(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"success": True}) - client.mfa.verify("mfa-1", token="123456") # noqa: S106 + client.mfa.verify("mfa-1", token="123456") mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/relay/rest/mfa/mfa-1/verify", - json={"token": "123456"}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/relay/rest/mfa/mfa-1/verify", + json={"token": "123456"}, + params=None, + timeout=30.0, ) @@ -126,24 +166,33 @@ def test_search(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"data": []}) client.datasphere.documents.search(query_string="billing") mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/datasphere/documents/search", - json={"query_string": "billing"}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/datasphere/documents/search", + json={"query_string": "billing"}, + params=None, + timeout=30.0, ) def test_list_chunks(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"data": []}) client.datasphere.documents.list_chunks("doc-1") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/datasphere/documents/doc-1/chunks", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/datasphere/documents/doc-1/chunks", + json=None, + params=None, + timeout=30.0, ) def test_delete_chunk(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(204, None, content=b"") client.datasphere.documents.delete_chunk("doc-1", "chunk-1") mock_session.request.assert_called_with( - "DELETE", "https://test.signalwire.com/api/datasphere/documents/doc-1/chunks/chunk-1", - json=None, params=None, timeout=30.0, + "DELETE", + "https://test.signalwire.com/api/datasphere/documents/doc-1/chunks/chunk-1", + json=None, + params=None, + timeout=30.0, ) @@ -152,32 +201,48 @@ def test_rooms_create(self, client: RestClient, mock_session: MagicMock) -> None mock_session.request.return_value = MockResponse(201, {"id": "room-1"}) client.video.rooms.create(name="standup") mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/video/rooms", - json={"name": "standup"}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/video/rooms", + json={"name": "standup"}, + params=None, + timeout=30.0, ) - def test_room_tokens_create(self, client: RestClient, mock_session: MagicMock) -> None: + def test_room_tokens_create( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(200, {"token": "abc"}) client.video.room_tokens.create(room_name="standup", user_name="alice") mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/video/room_tokens", - json={"room_name": "standup", "user_name": "alice"}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/video/room_tokens", + json={"room_name": "standup", "user_name": "alice"}, + params=None, + timeout=30.0, ) def test_session_members(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"data": []}) client.video.room_sessions.list_members("sess-1") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/video/room_sessions/sess-1/members", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/video/room_sessions/sess-1/members", + json=None, + params=None, + timeout=30.0, ) - def test_conference_streams(self, client: RestClient, mock_session: MagicMock) -> None: + def test_conference_streams( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(201, {}) client.video.conferences.create_stream("conf-1", url="rtmp://example.com/live") mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/video/conferences/conf-1/streams", - json={"url": "rtmp://example.com/live"}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/video/conferences/conf-1/streams", + json={"url": "rtmp://example.com/live"}, + params=None, + timeout=30.0, ) @@ -186,8 +251,11 @@ def test_voice_events(self, client: RestClient, mock_session: MagicMock) -> None mock_session.request.return_value = MockResponse(200, {"data": []}) client.logs.voice.list_events("log-1") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/voice/logs/log-1/events", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/voice/logs/log-1/events", + json=None, + params=None, + timeout=30.0, ) @@ -201,16 +269,22 @@ def test_create_brand(self, client: RestClient, mock_session: MagicMock) -> None } client.registry.brands.create(body) mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/relay/rest/registry/beta/brands", - json=body, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/relay/rest/registry/beta/brands", + json=body, + params=None, + timeout=30.0, ) def test_campaign_orders(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"data": []}) client.registry.campaigns.list_orders("camp-1") mock_session.request.assert_called_with( - "GET", "https://test.signalwire.com/api/relay/rest/registry/beta/campaigns/camp-1/orders", - json=None, params=None, timeout=30.0, + "GET", + "https://test.signalwire.com/api/relay/rest/registry/beta/campaigns/camp-1/orders", + json=None, + params=None, + timeout=30.0, ) @@ -219,8 +293,11 @@ def test_create_token(self, client: RestClient, mock_session: MagicMock) -> None mock_session.request.return_value = MockResponse(200, {"id": "tok-1"}) client.project.tokens.create(name="test-token", permissions=["calling"]) mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/project/tokens", - json={"name": "test-token", "permissions": ["calling"]}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/project/tokens", + json={"name": "test-token", "permissions": ["calling"]}, + params=None, + timeout=30.0, ) @@ -229,16 +306,22 @@ def test_pubsub_token(self, client: RestClient, mock_session: MagicMock) -> None mock_session.request.return_value = MockResponse(200, {"token": "abc"}) client.pubsub.create_token(ttl=60, channels={"room": {"read": True}}) mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/pubsub/tokens", - json={"ttl": 60, "channels": {"room": {"read": True}}}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/pubsub/tokens", + json={"ttl": 60, "channels": {"room": {"read": True}}}, + params=None, + timeout=30.0, ) def test_chat_token(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"token": "abc"}) client.chat.create_token(ttl=60, channels={"room": {"read": True}}) mock_session.request.assert_called_with( - "POST", "https://test.signalwire.com/api/chat/tokens", - json={"ttl": 60, "channels": {"room": {"read": True}}}, params=None, timeout=30.0, + "POST", + "https://test.signalwire.com/api/chat/tokens", + json={"ttl": 60, "channels": {"room": {"read": True}}}, + params=None, + timeout=30.0, ) @@ -254,7 +337,8 @@ def test_shim_warns_with_real_import_path(self) -> None: with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - import signalwire.rest.namespaces.calling # noqa: F401 + import signalwire.rest.namespaces.calling + importlib.reload(signalwire.rest.namespaces.calling) dep = [x for x in w if issubclass(x.category, DeprecationWarning)] assert dep, "shim did not warn" diff --git a/tests/unit/rest/test_pagination_mock.py b/tests/unit/rest/test_pagination_mock.py index dbc55ace..c65fd9ba 100644 --- a/tests/unit/rest/test_pagination_mock.py +++ b/tests/unit/rest/test_pagination_mock.py @@ -26,7 +26,9 @@ _FABRIC_ADDRESSES_ENDPOINT_ID = "fabric.list_fabric_addresses" -def _push_scenario(mock: _MockHarness, endpoint_id: str, status: int, response: dict[str, Any]) -> None: +def _push_scenario( + mock: _MockHarness, endpoint_id: str, status: int, response: dict[str, Any] +) -> None: """Push one consume-once scenario, scoped to THIS client's auth header. Scoping (``mock.push_scenario`` -> ``?session_id=``) keeps a @@ -36,7 +38,9 @@ def _push_scenario(mock: _MockHarness, endpoint_id: str, status: int, response: class TestPaginatedIterator: - def test_init_state(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_init_state( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: """Constructor records http/path/params/data_key without fetching.""" it = PaginatedIterator( signalwire_client._http, @@ -55,7 +59,9 @@ def test_init_state(self, signalwire_client: RestClient, mock: _MockHarness) -> # Journal must be empty — no HTTP went out. assert mock.journal == [] - def test_iter_returns_self(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_iter_returns_self( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: """``__iter__`` returns the iterator itself; ``iter(it)`` is the same.""" it = PaginatedIterator( signalwire_client._http, @@ -70,7 +76,9 @@ def test_iter_returns_self(self, signalwire_client: RestClient, mock: _MockHarne # Still no HTTP yet. assert mock.journal == [] - def test_next_pages_through_all_items(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_next_pages_through_all_items( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: """Walks two pages and stops on the page without ``links.next``. A fresh per-test client starts with an empty (auth-scoped) journal and @@ -81,19 +89,23 @@ def test_next_pages_through_all_items(self, signalwire_client: RestClient, mock: # that starts with PA/PB), NOT a ``cursor`` param (which no SignalWire REST # endpoint accepts — see rest-apis/fabric/openapi.yaml ListFabricAddressesQuery). _push_scenario( - mock, _FABRIC_ADDRESSES_ENDPOINT_ID, + mock, + _FABRIC_ADDRESSES_ENDPOINT_ID, status=200, response={ "data": [ {"id": "addr-1", "name": "first"}, {"id": "addr-2", "name": "second"}, ], - "links": {"next": "http://example.com/api/fabric/addresses?page_token=PA_page2"}, + "links": { + "next": "http://example.com/api/fabric/addresses?page_token=PA_page2" + }, }, ) # Page 2 — terminal (no next). _push_scenario( - mock, _FABRIC_ADDRESSES_ENDPOINT_ID, + mock, + _FABRIC_ADDRESSES_ENDPOINT_ID, status=200, response={ "data": [ @@ -123,11 +135,14 @@ def test_next_pages_through_all_items(self, signalwire_client: RestClient, mock: f"second fetch missing page_token=PA_page2: {gets[1].query_params}" ) - def test_next_raises_stop_iteration_when_done(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_next_raises_stop_iteration_when_done( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: """After exhausting items and seeing no next cursor, raise StopIteration.""" # One terminal page. _push_scenario( - mock, _FABRIC_ADDRESSES_ENDPOINT_ID, + mock, + _FABRIC_ADDRESSES_ENDPOINT_ID, status=200, response={ "data": [{"id": "only-one"}], @@ -169,7 +184,8 @@ def test_empty_page_with_next_continues( """ # Page 1 — EMPTY data but a next cursor pointing at page 2. _push_scenario( - mock, _FABRIC_ADDRESSES_ENDPOINT_ID, + mock, + _FABRIC_ADDRESSES_ENDPOINT_ID, status=200, response={ "data": [], @@ -178,7 +194,8 @@ def test_empty_page_with_next_continues( ) # Page 2 — the real item, terminal (no next). _push_scenario( - mock, _FABRIC_ADDRESSES_ENDPOINT_ID, + mock, + _FABRIC_ADDRESSES_ENDPOINT_ID, status=200, response={ "data": [{"id": "addr-late", "name": "found-after-empty-page"}], @@ -218,14 +235,18 @@ def test_resource_paginate_walks_all_pages( from signalwire.rest._base import ReadResource _push_scenario( - mock, _FABRIC_ADDRESSES_ENDPOINT_ID, status=200, + mock, + _FABRIC_ADDRESSES_ENDPOINT_ID, + status=200, response={ "data": [{"id": "r-1"}, {"id": "r-2"}], "links": {"next": f"{_FABRIC_ADDRESSES_PATH}?page_token=PA_page2"}, }, ) _push_scenario( - mock, _FABRIC_ADDRESSES_ENDPOINT_ID, status=200, + mock, + _FABRIC_ADDRESSES_ENDPOINT_ID, + status=200, response={"data": [{"id": "r-3"}], "links": {}}, ) @@ -250,12 +271,14 @@ def test_repeating_next_link_terminates( # cursor REPEATS (page 2's next == page 2's own request), so iteration ends # after consuming both pages' items instead of looping on page 3, 4, …. _push_scenario( - mock, _FABRIC_ADDRESSES_ENDPOINT_ID, + mock, + _FABRIC_ADDRESSES_ENDPOINT_ID, status=200, response={"data": [{"id": "a-1"}], "links": {"next": same_next}}, ) _push_scenario( - mock, _FABRIC_ADDRESSES_ENDPOINT_ID, + mock, + _FABRIC_ADDRESSES_ENDPOINT_ID, status=200, response={"data": [{"id": "a-2"}], "links": {"next": same_next}}, ) diff --git a/tests/unit/rest/test_phone_numbers.py b/tests/unit/rest/test_phone_numbers.py index 390d6b85..f1c48797 100644 --- a/tests/unit/rest/test_phone_numbers.py +++ b/tests/unit/rest/test_phone_numbers.py @@ -8,8 +8,6 @@ post-mortem. """ - - from signalwire.rest import PhoneCallHandler from .conftest import MockResponse @@ -25,7 +23,11 @@ def test_list(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"data": []}) client.phone_numbers.list() mock_session.request.assert_called_with( - "GET", BASE, json=None, params=None, timeout=30.0, + "GET", + BASE, + json=None, + params=None, + timeout=30.0, ) def test_search(self, client: RestClient, mock_session: MagicMock) -> None: @@ -38,7 +40,11 @@ def test_search(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {"data": []}) client.phone_numbers.search(areacode="512") mock_session.request.assert_called_with( - "GET", f"{BASE}/search", json=None, params={"areacode": "512"}, timeout=30.0, + "GET", + f"{BASE}/search", + json=None, + params={"areacode": "512"}, + timeout=30.0, ) def test_update_uses_put(self, client: RestClient, mock_session: MagicMock) -> None: @@ -46,7 +52,11 @@ def test_update_uses_put(self, client: RestClient, mock_session: MagicMock) -> N mock_session.request.return_value = MockResponse(200, {}) client.phone_numbers.update("pn-1", name="Main") mock_session.request.assert_called_with( - "PUT", f"{BASE}/pn-1", json={"name": "Main"}, params=None, timeout=30.0, + "PUT", + f"{BASE}/pn-1", + json={"name": "Main"}, + params=None, + timeout=30.0, ) @@ -54,10 +64,19 @@ class TestPhoneCallHandlerEnum: def test_all_wire_values_present(self) -> None: """Every call_handler value accepted by the API is in the enum.""" expected = { - "relay_context", "relay_topic", "relay_script", - "relay_application", "relay_connector", "relay_sip_endpoint", - "relay_verto_endpoint", "laml_webhooks", "laml_application", - "dialogflow", "video_room", "ai_agent", "call_flow", + "relay_context", + "relay_topic", + "relay_script", + "relay_application", + "relay_connector", + "relay_sip_endpoint", + "relay_verto_endpoint", + "laml_webhooks", + "laml_application", + "dialogflow", + "video_room", + "ai_agent", + "call_flow", } assert {h.value for h in PhoneCallHandler} == expected @@ -81,6 +100,7 @@ def test_no_collision_with_relay_callhandler(self) -> None: # Just assert the import path is the rest module; RELAY has its own # callback types elsewhere and won't reuse this symbol. from signalwire.rest import PhoneCallHandler as ReimportedHandler + assert ReimportedHandler is PhoneCallHandler @@ -88,21 +108,28 @@ class TestSetSwmlWebhook: def test_happy_path(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {}) client.phone_numbers.set_swml_webhook( - "pn-1", url="https://example.com/swml", + "pn-1", + url="https://example.com/swml", ) mock_session.request.assert_called_with( - "PUT", f"{BASE}/pn-1", + "PUT", + f"{BASE}/pn-1", json={ "call_handler": "relay_script", "call_relay_script_url": "https://example.com/swml", }, - params=None, timeout=30.0, + params=None, + timeout=30.0, ) - def test_extra_kwargs_pass_through(self, client: RestClient, mock_session: MagicMock) -> None: + def test_extra_kwargs_pass_through( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(200, {}) client.phone_numbers.set_swml_webhook( - "pn-1", url="https://example.com/swml", name="Support Line", + "pn-1", + url="https://example.com/swml", + name="Support Line", ) body = mock_session.request.call_args.kwargs["json"] assert body["name"] == "Support Line" @@ -114,18 +141,23 @@ class TestSetCxmlWebhook: def test_minimal(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {}) client.phone_numbers.set_cxml_webhook( - "pn-1", url="https://example.com/voice.xml", + "pn-1", + url="https://example.com/voice.xml", ) mock_session.request.assert_called_with( - "PUT", f"{BASE}/pn-1", + "PUT", + f"{BASE}/pn-1", json={ "call_handler": "laml_webhooks", "call_request_url": "https://example.com/voice.xml", }, - params=None, timeout=30.0, + params=None, + timeout=30.0, ) - def test_with_fallback_and_status(self, client: RestClient, mock_session: MagicMock) -> None: + def test_with_fallback_and_status( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(200, {}) client.phone_numbers.set_cxml_webhook( "pn-1", @@ -177,7 +209,9 @@ def test_minimal(self, client: RestClient, mock_session: MagicMock) -> None: def test_with_version(self, client: RestClient, mock_session: MagicMock) -> None: mock_session.request.return_value = MockResponse(200, {}) client.phone_numbers.set_call_flow( - "pn-1", flow_id="cf-1", version="current_deployed", + "pn-1", + flow_id="cf-1", + version="current_deployed", ) body = mock_session.request.call_args.kwargs["json"] assert body == { @@ -208,10 +242,13 @@ def test_minimal(self, client: RestClient, mock_session: MagicMock) -> None: "call_relay_topic": "office", } - def test_with_status_callback(self, client: RestClient, mock_session: MagicMock) -> None: + def test_with_status_callback( + self, client: RestClient, mock_session: MagicMock + ) -> None: mock_session.request.return_value = MockResponse(200, {}) client.phone_numbers.set_relay_topic( - "pn-1", topic="office", + "pn-1", + topic="office", status_callback_url="https://example.com/status", ) body = mock_session.request.call_args.kwargs["json"] @@ -233,11 +270,14 @@ class TestBindingRegressionPostMortem: (directly or via the typed helpers). This test pins that contract. """ - def test_swml_binding_uses_only_phone_numbers_update(self, client: RestClient, mock_session: MagicMock) -> None: + def test_swml_binding_uses_only_phone_numbers_update( + self, client: RestClient, mock_session: MagicMock + ) -> None: """The full happy path is a single PUT to /api/relay/rest/phone_numbers/{sid}.""" mock_session.request.return_value = MockResponse(200, {}) client.phone_numbers.set_swml_webhook( - "pn-1", url="https://example.com/swml", + "pn-1", + url="https://example.com/swml", ) calls = mock_session.request.call_args_list @@ -254,7 +294,9 @@ def test_swml_binding_uses_only_phone_numbers_update(self, client: RestClient, m # /api/fabric/resources/.../phone_routes) assert "/phone_routes" not in url - def test_wire_level_form_works_without_enum(self, client: RestClient, mock_session: MagicMock) -> None: + def test_wire_level_form_works_without_enum( + self, client: RestClient, mock_session: MagicMock + ) -> None: """Passing the raw string value also works — for users who don't import the enum.""" mock_session.request.return_value = MockResponse(200, {}) client.phone_numbers.update( @@ -266,7 +308,9 @@ def test_wire_level_form_works_without_enum(self, client: RestClient, mock_sessi assert body["call_handler"] == "relay_script" assert body["call_relay_script_url"] == "https://example.com/swml" - def test_enum_value_is_accepted_by_update(self, client: RestClient, mock_session: MagicMock) -> None: + def test_enum_value_is_accepted_by_update( + self, client: RestClient, mock_session: MagicMock + ) -> None: """Passing PhoneCallHandler.RELAY_SCRIPT.value serializes identically.""" mock_session.request.return_value = MockResponse(200, {}) client.phone_numbers.update( diff --git a/tests/unit/rest/test_registry_mock.py b/tests/unit/rest/test_registry_mock.py index 5b8ff50c..edec9fa0 100644 --- a/tests/unit/rest/test_registry_mock.py +++ b/tests/unit/rest/test_registry_mock.py @@ -29,7 +29,9 @@ class TestRegistryBrands: """``client.registry.brands.*`` — list, get, list_campaigns, create_campaign.""" - def test_list_returns_dict(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_returns_dict( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.registry.brands.list() assert isinstance(body, dict), f"expected dict, got {type(body).__name__}" @@ -38,7 +40,9 @@ def test_list_returns_dict(self, signalwire_client: RestClient, mock: _MockHarne assert last.path == f"{_REG_BASE}/brands" assert last.matched_route is not None, "spec gap: brand list" - def test_get_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_uses_id_in_path( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.registry.brands.get("brand-77") assert isinstance(body, dict) # Single-brand endpoint synthesises one resource object. @@ -47,7 +51,9 @@ def test_get_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHar assert last.method == "GET" assert last.path == f"{_REG_BASE}/brands/brand-77" - def test_list_campaigns_uses_brand_subpath(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_campaigns_uses_brand_subpath( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.registry.brands.list_campaigns("brand-1") assert isinstance(body, dict) @@ -56,7 +62,9 @@ def test_list_campaigns_uses_brand_subpath(self, signalwire_client: RestClient, assert last.path == f"{_REG_BASE}/brands/brand-1/campaigns" assert last.matched_route is not None - def test_create_campaign_posts_to_brand_subpath(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_create_campaign_posts_to_brand_subpath( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: # create_campaign takes the full CreateManagedCampaignRequest body; the # managed-campaign schema requires every field below. body = signalwire_client.registry.brands.create_campaign( @@ -98,7 +106,9 @@ def test_create_campaign_posts_to_brand_subpath(self, signalwire_client: RestCli class TestRegistryCampaigns: """``client.registry.campaigns.*`` — get, update (PUT), list_numbers, create_order.""" - def test_get_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_uses_id_in_path( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.registry.campaigns.get("camp-1") assert isinstance(body, dict) @@ -106,7 +116,9 @@ def test_get_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHar assert last.method == "GET" assert last.path == f"{_REG_BASE}/campaigns/camp-1" - def test_update_uses_put(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_update_uses_put( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: # RegistryCampaigns.update calls self._http.put(...) — distinct from # the generic CrudResource which uses PATCH. # UpdateCampaignRequest exposes only ``name``. @@ -121,7 +133,9 @@ def test_update_uses_put(self, signalwire_client: RestClient, mock: _MockHarness assert isinstance(last.body, dict) assert last.body.get("name") == "Updated Campaign" - def test_list_numbers_uses_numbers_subpath(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_numbers_uses_numbers_subpath( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.registry.campaigns.list_numbers("camp-3") assert isinstance(body, dict) @@ -130,7 +144,9 @@ def test_list_numbers_uses_numbers_subpath(self, signalwire_client: RestClient, assert last.path == f"{_REG_BASE}/campaigns/camp-3/numbers" assert last.matched_route is not None - def test_create_order_posts_to_orders_subpath(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_create_order_posts_to_orders_subpath( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.registry.campaigns.create_order( "camp-4", phone_numbers=["+15558675309", "+15558675310"] ) @@ -151,7 +167,9 @@ def test_create_order_posts_to_orders_subpath(self, signalwire_client: RestClien class TestRegistryOrders: """``client.registry.orders.get`` — read-only, retrieve by id.""" - def test_get_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_uses_id_in_path( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.registry.orders.get("order-1") assert isinstance(body, dict) @@ -169,7 +187,9 @@ def test_get_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHar class TestRegistryNumbers: """``client.registry.numbers.delete`` — release a number.""" - def test_delete_uses_id_in_path(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_delete_uses_id_in_path( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.registry.numbers.delete("num-1") # SDK turns 204/empty into {} so we still get a dict back. assert isinstance(body, dict) diff --git a/tests/unit/rest/test_resource_request_options.py b/tests/unit/rest/test_resource_request_options.py index 729b828c..3d788567 100644 --- a/tests/unit/rest/test_resource_request_options.py +++ b/tests/unit/rest/test_resource_request_options.py @@ -25,7 +25,10 @@ from typing import TYPE_CHECKING +import pytest + from signalwire.rest import RequestOptions +from signalwire.rest._base import SignalWireRestError if TYPE_CHECKING: from signalwire.rest.client import RestClient @@ -65,10 +68,11 @@ def test_list_does_not_retry_without_request_options( mock.push_scenario( "relay-rest.list_addresses", 503, {"errors": [{"code": "X"}]} ) - try: + # Un-retried, the 503 surfaces as the REST error — assert that, so this + # control cannot pass by the call unexpectedly SUCCEEDING or by an + # unrelated failure. + with pytest.raises(SignalWireRestError): signalwire_client.addresses.list() - except Exception: - pass assert _attempts(mock, self._PATH, "GET") == 1 @@ -119,10 +123,8 @@ def test_create_does_not_retry_without_request_options( self, signalwire_client: RestClient, mock: _MockHarness ) -> None: mock.push_scenario("relay-rest.create_address", 503, {"error": "x"}) - try: + with pytest.raises(SignalWireRestError): self._create(signalwire_client) - except Exception: - pass assert _attempts(mock, self._PATH, "POST") == 1 @@ -145,8 +147,6 @@ def test_search_does_not_retry_without_request_options( self, signalwire_client: RestClient, mock: _MockHarness ) -> None: mock.push_scenario("datasphere.search_documents", 503, {"error": "x"}) - try: + with pytest.raises(SignalWireRestError): signalwire_client.datasphere.documents.search(query_string="hello") - except Exception: - pass assert _attempts(mock, self._PATH, "POST") == 1 diff --git a/tests/unit/rest/test_small_namespaces_mock.py b/tests/unit/rest/test_small_namespaces_mock.py index 2d0a1af7..5b781179 100644 --- a/tests/unit/rest/test_small_namespaces_mock.py +++ b/tests/unit/rest/test_small_namespaces_mock.py @@ -145,7 +145,9 @@ def test_get(self, signalwire_client: RestClient, mock: _MockHarness) -> None: def test_update(self, signalwire_client: RestClient, mock: _MockHarness) -> None: body = signalwire_client.short_codes.update( - "sc-1", name="Marketing SMS", message_handler="relay_context", + "sc-1", + name="Marketing SMS", + message_handler="relay_context", ) assert isinstance(body, dict) assert "id" in body @@ -235,19 +237,26 @@ def test_update(self, signalwire_client: RestClient, mock: _MockHarness) -> None class TestNumberGroups: - def test_list_memberships(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_memberships( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.number_groups.list_memberships( - "ng-1", page_size=10, + "ng-1", + page_size=10, ) assert isinstance(body, dict) assert "data" in body assert isinstance(body["data"], list) last = mock.last_request() assert last.method == "GET" - assert last.path == "/api/relay/rest/number_groups/ng-1/number_group_memberships" + assert ( + last.path == "/api/relay/rest/number_groups/ng-1/number_group_memberships" + ) assert last.query_params.get("page_size") == ["10"] - def test_delete_membership(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_delete_membership( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.number_groups.delete_membership("mem-1") assert body == {} or isinstance(body, dict) last = mock.last_request() @@ -264,7 +273,8 @@ def test_delete_membership(self, signalwire_client: RestClient, mock: _MockHarne class TestProjectTokens: def test_update(self, signalwire_client: RestClient, mock: _MockHarness) -> None: body = signalwire_client.project.tokens.update( - "tok-1", name="renamed-token", + "tok-1", + name="renamed-token", ) assert isinstance(body, dict) assert "id" in body @@ -291,7 +301,8 @@ def test_delete(self, signalwire_client: RestClient, mock: _MockHarness) -> None class TestDatasphere: def test_get_chunk(self, signalwire_client: RestClient, mock: _MockHarness) -> None: body = signalwire_client.datasphere.documents.get_chunk( - "doc-1", "chunk-99", + "doc-1", + "chunk-99", ) assert isinstance(body, dict) # The DatasphereChunk schema has an 'id'. @@ -307,7 +318,9 @@ def test_get_chunk(self, signalwire_client: RestClient, mock: _MockHarness) -> N class TestQueues: - def test_get_member(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_member( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.queues.get_member("q-1", "mem-7") assert isinstance(body, dict) # A queue member has 'queue_id' and 'call_id' per the spec example. diff --git a/tests/unit/rest/test_video_mock.py b/tests/unit/rest/test_video_mock.py index ef44526a..4979b763 100644 --- a/tests/unit/rest/test_video_mock.py +++ b/tests/unit/rest/test_video_mock.py @@ -27,7 +27,9 @@ class TestVideoRoomsStreams: """Streams that hang off a Video Room.""" - def test_list_streams_returns_data_collection(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_streams_returns_data_collection( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.rooms.list_streams("room-1") assert isinstance(body, dict), f"expected dict, got {type(body).__name__}" # /api/video/rooms/{id}/streams returns a paginated list ('data'). @@ -39,7 +41,9 @@ def test_list_streams_returns_data_collection(self, signalwire_client: RestClien assert last.path == "/api/video/rooms/room-1/streams" assert last.matched_route is not None, "spec gap: rooms streams list" - def test_create_stream_posts_kwargs_in_body(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_create_stream_posts_kwargs_in_body( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.rooms.create_stream( "room-1", url="rtmp://example.com/live" ) @@ -60,7 +64,9 @@ def test_create_stream_posts_kwargs_in_body(self, signalwire_client: RestClient, class TestVideoRoomSessions: """``client.video.room_sessions.*`` — list, get, sub-collections.""" - def test_list_returns_data_collection(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_returns_data_collection( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.room_sessions.list() assert isinstance(body, dict) assert "data" in body, f"missing 'data' in body keys {sorted(body)!r}" @@ -70,7 +76,9 @@ def test_list_returns_data_collection(self, signalwire_client: RestClient, mock: assert last.method == "GET" assert last.path == "/api/video/room_sessions" - def test_get_returns_session_object(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_returns_session_object( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.room_sessions.get("sess-abc") assert isinstance(body, dict) # /room_sessions/{id} synthesises a single resource object. @@ -80,7 +88,9 @@ def test_get_returns_session_object(self, signalwire_client: RestClient, mock: _ assert last.path == "/api/video/room_sessions/sess-abc" assert last.matched_route is not None - def test_list_events_uses_events_subpath(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_events_uses_events_subpath( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.room_sessions.list_events("sess-1") assert isinstance(body, dict) assert "data" in body and isinstance(body["data"], list) @@ -89,7 +99,9 @@ def test_list_events_uses_events_subpath(self, signalwire_client: RestClient, mo assert last.method == "GET" assert last.path == "/api/video/room_sessions/sess-1/events" - def test_list_recordings_uses_recordings_subpath(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_recordings_uses_recordings_subpath( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.room_sessions.list_recordings("sess-2") assert isinstance(body, dict) assert "data" in body @@ -107,7 +119,9 @@ def test_list_recordings_uses_recordings_subpath(self, signalwire_client: RestCl class TestVideoRoomRecordings: """``client.video.room_recordings.*`` — top-level recordings collection.""" - def test_list_returns_data_collection(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_returns_data_collection( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.room_recordings.list() assert isinstance(body, dict) assert "data" in body and isinstance(body["data"], list) @@ -116,7 +130,9 @@ def test_list_returns_data_collection(self, signalwire_client: RestClient, mock: assert last.method == "GET" assert last.path == "/api/video/room_recordings" - def test_get_returns_single_recording(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_returns_single_recording( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.room_recordings.get("rec-xyz") assert isinstance(body, dict) @@ -124,7 +140,9 @@ def test_get_returns_single_recording(self, signalwire_client: RestClient, mock: assert last.method == "GET" assert last.path == "/api/video/room_recordings/rec-xyz" - def test_delete_returns_empty_dict_for_204(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_delete_returns_empty_dict_for_204( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: # The mock synthesises 204/empty for DELETE which the SDK turns into {}. body = signalwire_client.video.room_recordings.delete("rec-del") assert isinstance(body, dict) @@ -134,7 +152,9 @@ def test_delete_returns_empty_dict_for_204(self, signalwire_client: RestClient, assert last.path == "/api/video/room_recordings/rec-del" assert last.matched_route is not None - def test_list_events_uses_events_subpath(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_events_uses_events_subpath( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.room_recordings.list_events("rec-1") assert isinstance(body, dict) assert "data" in body @@ -152,7 +172,9 @@ def test_list_events_uses_events_subpath(self, signalwire_client: RestClient, mo class TestVideoConferences: """Sub-collection endpoints on ``client.video.conferences``.""" - def test_list_conference_tokens(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_conference_tokens( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.conferences.list_conference_tokens("conf-1") assert isinstance(body, dict) # Token-collection endpoints return 'data' arrays. @@ -162,7 +184,9 @@ def test_list_conference_tokens(self, signalwire_client: RestClient, mock: _Mock assert last.method == "GET" assert last.path == "/api/video/conferences/conf-1/conference_tokens" - def test_list_streams(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_list_streams( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.conferences.list_streams("conf-2") assert isinstance(body, dict) assert "data" in body and isinstance(body["data"], list) @@ -180,7 +204,9 @@ def test_list_streams(self, signalwire_client: RestClient, mock: _MockHarness) - class TestVideoConferenceTokens: """``client.video.conference_tokens.*`` — get/reset for a token resource.""" - def test_get_returns_single_token(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_returns_single_token( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.conference_tokens.get("tok-1") assert isinstance(body, dict) @@ -189,7 +215,9 @@ def test_get_returns_single_token(self, signalwire_client: RestClient, mock: _Mo assert last.path == "/api/video/conference_tokens/tok-1" assert last.matched_route is not None - def test_reset_posts_to_reset_subpath(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_reset_posts_to_reset_subpath( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.conference_tokens.reset("tok-2") assert isinstance(body, dict) @@ -208,7 +236,9 @@ def test_reset_posts_to_reset_subpath(self, signalwire_client: RestClient, mock: class TestVideoStreams: """``client.video.streams.*`` — get / update (PUT) / delete by stream id.""" - def test_get_returns_stream_resource(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_get_returns_stream_resource( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: body = signalwire_client.video.streams.get("stream-1") assert isinstance(body, dict) @@ -216,7 +246,9 @@ def test_get_returns_stream_resource(self, signalwire_client: RestClient, mock: assert last.method == "GET" assert last.path == "/api/video/streams/stream-1" - def test_update_uses_put_with_kwargs(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + def test_update_uses_put_with_kwargs( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: # VideoStreams.update calls self._http.put(path, body=kwargs) body = signalwire_client.video.streams.update( "stream-2", url="rtmp://example.com/new" diff --git a/tests/unit/rest/wire_regression_pins_test.py b/tests/unit/rest/wire_regression_pins_test.py index a1d63d34..0d074aff 100644 --- a/tests/unit/rest/wire_regression_pins_test.py +++ b/tests/unit/rest/wire_regression_pins_test.py @@ -28,7 +28,7 @@ import re from importlib.metadata import version as _pkg_version -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar if TYPE_CHECKING: from signalwire.rest.client import RestClient @@ -55,7 +55,7 @@ class TestWirePercentEncodingPin: # ``list(**HOSTILE)`` splat lands cleanly on ``list``'s ``**params: Any`` query tail: # ``list`` now also declares a keyword-only ``request_options`` param before that tail, # and mypy would otherwise try to satisfy it from the unpacked ``str`` values. - HOSTILE: dict[str, Any] = { + HOSTILE: ClassVar[dict[str, Any]] = { "filter_label": "a b&c+d=é\N{SNOWMAN}", } diff --git a/tests/unit/search/test_document_processor.py b/tests/unit/search/test_document_processor.py index c0ac2ba8..23d4aefd 100644 --- a/tests/unit/search/test_document_processor.py +++ b/tests/unit/search/test_document_processor.py @@ -12,23 +12,20 @@ """ import pytest -import tempfile -import os from typing import Any from unittest.mock import Mock, patch, MagicMock, mock_open -from pathlib import Path from signalwire.search.document_processor import DocumentProcessor class TestDocumentProcessorInit: """Test DocumentProcessor initialization""" - + def test_default_initialization(self) -> None: """Test default initialization""" processor = DocumentProcessor() - - assert processor.chunking_strategy == 'sentence' + + assert processor.chunking_strategy == "sentence" assert processor.max_sentences_per_chunk == 5 assert processor.chunk_size == 50 assert processor.chunk_overlap == 10 @@ -36,18 +33,18 @@ def test_default_initialization(self) -> None: assert processor.chunk_overlap == 10 # Legacy support assert processor.semantic_threshold == 0.5 assert processor.topic_threshold == 0.3 - + def test_custom_initialization(self) -> None: """Test initialization with custom parameters""" processor = DocumentProcessor( - chunking_strategy='sliding', + chunking_strategy="sliding", max_sentences_per_chunk=25, chunk_size=100, chunk_overlap=20, - split_newlines=3 + split_newlines=3, ) - - assert processor.chunking_strategy == 'sliding' + + assert processor.chunking_strategy == "sliding" assert processor.max_sentences_per_chunk == 25 assert processor.chunk_size == 100 assert processor.chunk_overlap == 20 @@ -59,408 +56,444 @@ def test_custom_initialization(self) -> None: class TestDocumentProcessorChunking: """Test document chunking strategies""" - + def setup_method(self) -> None: """Set up test fixtures""" self.processor = DocumentProcessor() self.sample_text = "This is the first sentence. This is the second sentence. This is the third sentence." self.filename = "test.txt" self.file_type = "txt" - + def test_create_chunks_sentence_strategy(self) -> None: """Test create_chunks with sentence strategy""" - processor = DocumentProcessor(chunking_strategy='sentence', max_sentences_per_chunk=2) - - chunks = processor.create_chunks(self.sample_text, self.filename, self.file_type) - + processor = DocumentProcessor( + chunking_strategy="sentence", max_sentences_per_chunk=2 + ) + + chunks = processor.create_chunks( + self.sample_text, self.filename, self.file_type + ) + assert len(chunks) > 0 - assert all('content' in chunk for chunk in chunks) - assert all('filename' in chunk for chunk in chunks) - assert all('metadata' in chunk for chunk in chunks) - assert all(chunk['metadata']['chunk_method'] == 'sentence_based' for chunk in chunks) - + assert all("content" in chunk for chunk in chunks) + assert all("filename" in chunk for chunk in chunks) + assert all("metadata" in chunk for chunk in chunks) + assert all( + chunk["metadata"]["chunk_method"] == "sentence_based" for chunk in chunks + ) + def test_create_chunks_sliding_strategy(self) -> None: """Test create_chunks with sliding window strategy""" - processor = DocumentProcessor(chunking_strategy='sliding', chunk_size=5, chunk_overlap=2) - - chunks = processor.create_chunks(self.sample_text, self.filename, self.file_type) - + processor = DocumentProcessor( + chunking_strategy="sliding", chunk_size=5, chunk_overlap=2 + ) + + chunks = processor.create_chunks( + self.sample_text, self.filename, self.file_type + ) + assert len(chunks) > 0 - assert all('content' in chunk for chunk in chunks) - assert all(chunk['metadata']['chunk_method'] == 'sliding_window' for chunk in chunks) - assert all('chunk_size_words' in chunk['metadata'] for chunk in chunks) - assert all('overlap_size_words' in chunk['metadata'] for chunk in chunks) - + assert all("content" in chunk for chunk in chunks) + assert all( + chunk["metadata"]["chunk_method"] == "sliding_window" for chunk in chunks + ) + assert all("chunk_size_words" in chunk["metadata"] for chunk in chunks) + assert all("overlap_size_words" in chunk["metadata"] for chunk in chunks) + def test_create_chunks_paragraph_strategy(self) -> None: """Test create_chunks with paragraph strategy""" - processor = DocumentProcessor(chunking_strategy='paragraph') + processor = DocumentProcessor(chunking_strategy="paragraph") paragraph_text = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph." - + chunks = processor.create_chunks(paragraph_text, self.filename, self.file_type) - + assert len(chunks) == 3 - assert all(chunk['metadata']['chunk_method'] == 'paragraph_based' for chunk in chunks) - assert chunks[0]['content'] == "First paragraph." - assert chunks[1]['content'] == "Second paragraph." - assert chunks[2]['content'] == "Third paragraph." - + assert all( + chunk["metadata"]["chunk_method"] == "paragraph_based" for chunk in chunks + ) + assert chunks[0]["content"] == "First paragraph." + assert chunks[1]["content"] == "Second paragraph." + assert chunks[2]["content"] == "Third paragraph." + def test_create_chunks_page_strategy(self) -> None: """Test create_chunks with page strategy""" - processor = DocumentProcessor(chunking_strategy='page') - + processor = DocumentProcessor(chunking_strategy="page") + # Test with list input (like PDF pages) page_list = ["Page 1 content", "Page 2 content", "Page 3 content"] chunks = processor.create_chunks(page_list, self.filename, self.file_type) # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch - + assert len(chunks) == 3 - assert all(chunk['metadata']['chunk_method'] == 'page_based' for chunk in chunks) - assert chunks[0]['content'] == "Page 1 content" - assert chunks[1]['content'] == "Page 2 content" - assert chunks[2]['content'] == "Page 3 content" - + assert all( + chunk["metadata"]["chunk_method"] == "page_based" for chunk in chunks + ) + assert chunks[0]["content"] == "Page 1 content" + assert chunks[1]["content"] == "Page 2 content" + assert chunks[2]["content"] == "Page 3 content" + def test_create_chunks_fallback_strategy(self) -> None: """Test create_chunks with unknown strategy falls back to sentence""" - processor = DocumentProcessor(chunking_strategy='unknown') - - chunks = processor.create_chunks(self.sample_text, self.filename, self.file_type) - + processor = DocumentProcessor(chunking_strategy="unknown") + + chunks = processor.create_chunks( + self.sample_text, self.filename, self.file_type + ) + assert len(chunks) > 0 - assert all(chunk['metadata']['chunk_method'] == 'sentence_based' for chunk in chunks) + assert all( + chunk["metadata"]["chunk_method"] == "sentence_based" for chunk in chunks + ) class TestDocumentProcessorSentenceChunking: """Test sentence-based chunking functionality""" - + def test_chunk_by_sentences_basic(self) -> None: """Test basic sentence chunking""" processor = DocumentProcessor(max_sentences_per_chunk=2) content = "First sentence. Second sentence. Third sentence. Fourth sentence." - + chunks = processor._chunk_by_sentences(content, "test.txt", "txt") - + assert len(chunks) >= 1 - assert all('content' in chunk for chunk in chunks) - assert all(chunk['metadata']['chunk_method'] == 'sentence_based' for chunk in chunks) - assert all(chunk['metadata']['max_sentences_per_chunk'] == 2 for chunk in chunks) - + assert all("content" in chunk for chunk in chunks) + assert all( + chunk["metadata"]["chunk_method"] == "sentence_based" for chunk in chunks + ) + assert all( + chunk["metadata"]["max_sentences_per_chunk"] == 2 for chunk in chunks + ) + def test_chunk_by_sentences_with_list_input(self) -> None: """Test sentence chunking with list input""" processor = DocumentProcessor(max_sentences_per_chunk=2) content = ["First line.", "Second line.", "Third line."] - + chunks = processor._chunk_by_sentences(content, "test.txt", "txt") # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch - + assert len(chunks) >= 1 - assert all('content' in chunk for chunk in chunks) - + assert all("content" in chunk for chunk in chunks) + def test_chunk_by_sentences_with_split_newlines(self) -> None: """Test sentence chunking with split_newlines parameter""" processor = DocumentProcessor(max_sentences_per_chunk=5, split_newlines=2) content = "First sentence. Second sentence.\n\nThird sentence. Fourth sentence." - + chunks = processor._chunk_by_sentences(content, "test.txt", "txt") - + assert len(chunks) >= 1 - assert all(chunk['metadata']['split_newlines'] == 2 for chunk in chunks) + assert all(chunk["metadata"]["split_newlines"] == 2 for chunk in chunks) class TestDocumentProcessorSlidingWindow: """Test sliding window chunking functionality""" - + def test_chunk_by_sliding_window_basic(self) -> None: """Test basic sliding window chunking""" processor = DocumentProcessor(chunk_size=3, chunk_overlap=1) content = "one two three four five six seven eight" - + chunks = processor._chunk_by_sliding_window(content, "test.txt", "txt") - + assert len(chunks) > 1 - assert all(chunk['metadata']['chunk_method'] == 'sliding_window' for chunk in chunks) - assert all(chunk['metadata']['chunk_size_words'] == 3 for chunk in chunks) - assert all(chunk['metadata']['overlap_size_words'] == 1 for chunk in chunks) - + assert all( + chunk["metadata"]["chunk_method"] == "sliding_window" for chunk in chunks + ) + assert all(chunk["metadata"]["chunk_size_words"] == 3 for chunk in chunks) + assert all(chunk["metadata"]["overlap_size_words"] == 1 for chunk in chunks) + # Check overlap - first_chunk_words = chunks[0]['content'].split() - second_chunk_words = chunks[1]['content'].split() + first_chunk_words = chunks[0]["content"].split() + second_chunk_words = chunks[1]["content"].split() assert len(first_chunk_words) == 3 assert len(second_chunk_words) <= 3 - + def test_chunk_by_sliding_window_with_list_input(self) -> None: """Test sliding window chunking with list input""" processor = DocumentProcessor(chunk_size=2, chunk_overlap=1) content = ["line one", "line two", "line three"] - + chunks = processor._chunk_by_sliding_window(content, "test.txt", "txt") # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch - + assert len(chunks) >= 1 - assert all('content' in chunk for chunk in chunks) - + assert all("content" in chunk for chunk in chunks) + def test_chunk_by_sliding_window_empty_content(self) -> None: """Test sliding window chunking with empty content""" processor = DocumentProcessor(chunk_size=3, chunk_overlap=1) content = "" - + chunks = processor._chunk_by_sliding_window(content, "test.txt", "txt") - + assert chunks == [] - + def test_chunk_by_sliding_window_metadata(self) -> None: """Test sliding window chunking metadata""" processor = DocumentProcessor(chunk_size=2, chunk_overlap=1) content = "word1 word2 word3 word4" - + chunks = processor._chunk_by_sliding_window(content, "test.txt", "txt") - + assert len(chunks) >= 2 - assert chunks[0]['metadata']['start_word'] == 0 - assert chunks[0]['metadata']['end_word'] == 2 - assert chunks[1]['metadata']['start_word'] == 1 # overlap - assert chunks[1]['metadata']['end_word'] == 3 + assert chunks[0]["metadata"]["start_word"] == 0 + assert chunks[0]["metadata"]["end_word"] == 2 + assert chunks[1]["metadata"]["start_word"] == 1 # overlap + assert chunks[1]["metadata"]["end_word"] == 3 class TestDocumentProcessorParagraphChunking: """Test paragraph-based chunking functionality""" - + def test_chunk_by_paragraphs_basic(self) -> None: """Test basic paragraph chunking""" processor = DocumentProcessor() content = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph." - + chunks = processor._chunk_by_paragraphs(content, "test.txt", "txt") - + assert len(chunks) == 3 - assert chunks[0]['content'] == "First paragraph." - assert chunks[1]['content'] == "Second paragraph." - assert chunks[2]['content'] == "Third paragraph." - assert all(chunk['metadata']['chunk_method'] == 'paragraph_based' for chunk in chunks) - + assert chunks[0]["content"] == "First paragraph." + assert chunks[1]["content"] == "Second paragraph." + assert chunks[2]["content"] == "Third paragraph." + assert all( + chunk["metadata"]["chunk_method"] == "paragraph_based" for chunk in chunks + ) + def test_chunk_by_paragraphs_with_list_input(self) -> None: """Test paragraph chunking with list input""" processor = DocumentProcessor() content = ["First line", "Second line", "", "Third line"] - + chunks = processor._chunk_by_paragraphs(content, "test.txt", "txt") # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch - + assert len(chunks) >= 1 - assert all('content' in chunk for chunk in chunks) - + assert all("content" in chunk for chunk in chunks) + def test_chunk_by_paragraphs_with_whitespace(self) -> None: """Test paragraph chunking with various whitespace""" processor = DocumentProcessor() content = "Para 1.\n \n Para 2. \n\n\n Para 3." - + chunks = processor._chunk_by_paragraphs(content, "test.txt", "txt") - + assert len(chunks) == 3 - assert chunks[0]['content'] == "Para 1." - assert chunks[1]['content'] == "Para 2." - assert chunks[2]['content'] == "Para 3." - + assert chunks[0]["content"] == "Para 1." + assert chunks[1]["content"] == "Para 2." + assert chunks[2]["content"] == "Para 3." + def test_chunk_by_paragraphs_empty_paragraphs(self) -> None: """Test paragraph chunking skips empty paragraphs""" processor = DocumentProcessor() content = "Para 1.\n\n\n\nPara 2.\n\n" - + chunks = processor._chunk_by_paragraphs(content, "test.txt", "txt") - + assert len(chunks) == 2 - assert chunks[0]['content'] == "Para 1." - assert chunks[1]['content'] == "Para 2." + assert chunks[0]["content"] == "Para 1." + assert chunks[1]["content"] == "Para 2." class TestDocumentProcessorPageChunking: """Test page-based chunking functionality""" - + def test_chunk_by_pages_with_list(self) -> None: """Test page chunking with list input (like PDF)""" processor = DocumentProcessor() content = ["Page 1 content", "Page 2 content", "", "Page 3 content"] - + chunks = processor._chunk_by_pages(content, "test.txt", "txt") # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch - + assert len(chunks) == 3 # Empty page should be skipped - assert chunks[0]['content'] == "Page 1 content" - assert chunks[1]['content'] == "Page 2 content" - assert chunks[2]['content'] == "Page 3 content" - assert all(chunk['metadata']['chunk_method'] == 'page_based' for chunk in chunks) - + assert chunks[0]["content"] == "Page 1 content" + assert chunks[1]["content"] == "Page 2 content" + assert chunks[2]["content"] == "Page 3 content" + assert all( + chunk["metadata"]["chunk_method"] == "page_based" for chunk in chunks + ) + def test_chunk_by_pages_with_form_feeds(self) -> None: """Test page chunking with form feed characters""" processor = DocumentProcessor() content = "Page 1 content\fPage 2 content\fPage 3 content" - + chunks = processor._chunk_by_pages(content, "test.txt", "txt") - + assert len(chunks) == 3 - assert chunks[0]['content'] == "Page 1 content" - assert chunks[1]['content'] == "Page 2 content" - assert chunks[2]['content'] == "Page 3 content" - + assert chunks[0]["content"] == "Page 1 content" + assert chunks[1]["content"] == "Page 2 content" + assert chunks[2]["content"] == "Page 3 content" + def test_chunk_by_pages_with_page_markers(self) -> None: """Test page chunking with page break markers""" processor = DocumentProcessor() content = "Page 1 content---PAGE---Page 2 content---PAGE---Page 3 content" - + chunks = processor._chunk_by_pages(content, "test.txt", "txt") - + assert len(chunks) == 3 - assert chunks[0]['content'] == "Page 1 content" - assert chunks[1]['content'] == "Page 2 content" - assert chunks[2]['content'] == "Page 3 content" - + assert chunks[0]["content"] == "Page 1 content" + assert chunks[1]["content"] == "Page 2 content" + assert chunks[2]["content"] == "Page 3 content" + def test_chunk_by_pages_fallback_chunking(self) -> None: """Test page chunking fallback for plain text""" processor = DocumentProcessor() # Create content that will trigger fallback chunking words = ["word"] * 1000 # 1000 words content = " ".join(words) - + chunks = processor._chunk_by_pages(content, "test.txt", "txt") - + assert len(chunks) > 1 # Should create multiple chunks - assert all(chunk['metadata']['chunk_method'] == 'page_based' for chunk in chunks) - assert all('page_number' in chunk['metadata'] for chunk in chunks) + assert all( + chunk["metadata"]["chunk_method"] == "page_based" for chunk in chunks + ) + assert all("page_number" in chunk["metadata"] for chunk in chunks) class TestDocumentProcessorFileExtraction: """Test file extraction methods""" - + def setup_method(self) -> None: """Set up test fixtures""" self.processor = DocumentProcessor() - - @patch('signalwire.search.document_processor.magic', None) + + @patch("signalwire.search.document_processor.magic", None) def test_extract_text_from_file_no_magic(self) -> None: """Test file extraction without magic library""" - with patch.object(self.processor, '_extract_text') as mock_extract: + with patch.object(self.processor, "_extract_text") as mock_extract: mock_extract.return_value = "test content" - + result = self.processor._extract_text_from_file("test.txt") - + mock_extract.assert_called_once_with("test.txt") assert result == "test content" - - @patch('signalwire.search.document_processor.magic') + + @patch("signalwire.search.document_processor.magic") def test_extract_text_from_file_with_magic(self, mock_magic: MagicMock) -> None: """Test file extraction with magic library""" mock_mime = Mock() mock_mime.from_file.return_value = "text/plain" mock_magic.Magic.return_value = mock_mime - - with patch.object(self.processor, '_extract_text') as mock_extract: + + with patch.object(self.processor, "_extract_text") as mock_extract: mock_extract.return_value = "test content" - + result = self.processor._extract_text_from_file("test.txt") - + mock_extract.assert_called_once_with("test.txt") assert result == "test content" - + def test_extract_text_from_file_pdf(self) -> None: """Test PDF file extraction""" - with patch.object(self.processor, '_extract_pdf') as mock_extract: + with patch.object(self.processor, "_extract_pdf") as mock_extract: mock_extract.return_value = ["page 1", "page 2"] - + result = self.processor._extract_text_from_file("test.pdf") - + mock_extract.assert_called_once_with("test.pdf") assert result == ["page 1", "page 2"] - + def test_extract_text_from_file_docx(self) -> None: """Test DOCX file extraction""" - with patch.object(self.processor, '_extract_docx') as mock_extract: + with patch.object(self.processor, "_extract_docx") as mock_extract: mock_extract.return_value = ["paragraph 1", "paragraph 2"] - + result = self.processor._extract_text_from_file("test.docx") - + mock_extract.assert_called_once_with("test.docx") assert result == ["paragraph 1", "paragraph 2"] - + def test_extract_text_from_file_html(self) -> None: """Test HTML file extraction""" # HTML files with fallback detection go to _extract_text due to 'text' in 'text/html' - with patch('signalwire.search.document_processor.magic', None): - with patch.object(self.processor, '_extract_text') as mock_extract: - mock_extract.return_value = "html content" - - result = self.processor._extract_text_from_file("test.html") - - mock_extract.assert_called_once_with("test.html") - assert result == "html content" - + with ( + patch("signalwire.search.document_processor.magic", None), + patch.object(self.processor, "_extract_text") as mock_extract, + ): + mock_extract.return_value = "html content" + + result = self.processor._extract_text_from_file("test.html") + + mock_extract.assert_called_once_with("test.html") + assert result == "html content" + def test_extract_text_from_file_markdown(self) -> None: """Test Markdown file extraction""" # Markdown files with fallback detection go to _extract_text due to 'text' in 'text/plain' - with patch('signalwire.search.document_processor.magic', None): - with patch.object(self.processor, '_extract_text') as mock_extract: - mock_extract.return_value = "markdown content" - - result = self.processor._extract_text_from_file("test.md") - - mock_extract.assert_called_once_with("test.md") - assert result == "markdown content" - + with ( + patch("signalwire.search.document_processor.magic", None), + patch.object(self.processor, "_extract_text") as mock_extract, + ): + mock_extract.return_value = "markdown content" + + result = self.processor._extract_text_from_file("test.md") + + mock_extract.assert_called_once_with("test.md") + assert result == "markdown content" + def test_extract_text_from_file_unsupported(self) -> None: """Test unsupported file type""" - with patch('signalwire.search.document_processor.magic') as mock_magic: + with patch("signalwire.search.document_processor.magic") as mock_magic: mock_mime = Mock() mock_mime.from_file.return_value = "application/unknown" mock_magic.Magic.return_value = mock_mime - + result = self.processor._extract_text_from_file("test.unknown") - + assert "Unsupported file type" in result class TestDocumentProcessorSpecificExtractors: """Test specific file format extractors""" - + def setup_method(self) -> None: """Set up test fixtures""" self.processor = DocumentProcessor() - - @patch('signalwire.search.document_processor.pdfplumber', None) + + @patch("signalwire.search.document_processor.pdfplumber", None) def test_extract_pdf_no_library(self) -> None: """Test PDF extraction without pdfplumber""" result = self.processor._extract_pdf("test.pdf") - + assert "pdfplumber not available" in result - - @patch('signalwire.search.document_processor.pdfplumber') + + @patch("signalwire.search.document_processor.pdfplumber") def test_extract_pdf_success(self, mock_pdfplumber: MagicMock) -> None: """Test successful PDF extraction""" mock_page1 = Mock() mock_page1.extract_text.return_value = "Page 1 content" mock_page2 = Mock() mock_page2.extract_text.return_value = "Page 2 content" - + mock_pdf = Mock() mock_pdf.pages = [mock_page1, mock_page2] mock_pdf.__enter__ = Mock(return_value=mock_pdf) mock_pdf.__exit__ = Mock(return_value=None) - + mock_pdfplumber.open.return_value = mock_pdf - + result = self.processor._extract_pdf("test.pdf") - + assert result == ["Page 1 content", "Page 2 content"] - - @patch('signalwire.search.document_processor.pdfplumber') + + @patch("signalwire.search.document_processor.pdfplumber") def test_extract_pdf_error(self, mock_pdfplumber: MagicMock) -> None: """Test PDF extraction with error""" mock_pdfplumber.open.side_effect = Exception("PDF error") - + result = self.processor._extract_pdf("test.pdf") - + assert "Error processing PDF" in result - - @patch('signalwire.search.document_processor.DocxDocument', None) + + @patch("signalwire.search.document_processor.DocxDocument", None) def test_extract_docx_no_library(self) -> None: """Test DOCX extraction without python-docx""" result = self.processor._extract_docx("test.docx") - + assert "python-docx not available" in result - - @patch('signalwire.search.document_processor.DocxDocument') + + @patch("signalwire.search.document_processor.DocxDocument") def test_extract_docx_success(self, mock_docx: MagicMock) -> None: """Test successful DOCX extraction""" mock_para1 = Mock() @@ -469,241 +502,249 @@ def test_extract_docx_success(self, mock_docx: MagicMock) -> None: mock_para2.text = "Paragraph 2" mock_para3 = Mock() mock_para3.text = "" # Empty paragraph should be filtered - + mock_doc = Mock() mock_doc.paragraphs = [mock_para1, mock_para2, mock_para3] mock_docx.return_value = mock_doc - + result = self.processor._extract_docx("test.docx") - + assert result == ["Paragraph 1", "Paragraph 2"] - - @patch('signalwire.search.document_processor.DocxDocument') + + @patch("signalwire.search.document_processor.DocxDocument") def test_extract_docx_error(self, mock_docx: MagicMock) -> None: """Test DOCX extraction with error""" mock_docx.side_effect = Exception("DOCX error") - + result = self.processor._extract_docx("test.docx") - + assert "Error processing DOCX" in result - + def test_extract_text_success(self) -> None: """Test successful text file extraction""" - with patch('builtins.open', mock_open(read_data="test content")): + with patch("builtins.open", mock_open(read_data="test content")): result = self.processor._extract_text("test.txt") - + assert result == "test content" - + def test_extract_text_error(self) -> None: """Test text file extraction with error""" - with patch('builtins.open', side_effect=Exception("File error")): + with patch("builtins.open", side_effect=Exception("File error")): result = self.processor._extract_text("test.txt") - + assert "Error processing TXT" in result - - @patch('signalwire.search.document_processor.BeautifulSoup', None) + + @patch("signalwire.search.document_processor.BeautifulSoup", None) def test_extract_html_no_library(self) -> None: """Test HTML extraction without BeautifulSoup""" result = self.processor._extract_html("test.html") - + assert "beautifulsoup4 not available" in result - - @patch('signalwire.search.document_processor.BeautifulSoup') + + @patch("signalwire.search.document_processor.BeautifulSoup") def test_extract_html_success(self, mock_bs: MagicMock) -> None: """Test successful HTML extraction""" mock_soup = Mock() mock_soup.get_text.return_value = "HTML content" mock_bs.return_value = mock_soup - - with patch('builtins.open', mock_open(read_data="HTML content")): + + with patch( + "builtins.open", + mock_open(read_data="HTML content"), + ): result = self.processor._extract_html("test.html") - + assert result == "HTML content" - - @patch('signalwire.search.document_processor.markdown', None) + + @patch("signalwire.search.document_processor.markdown", None) def test_extract_markdown_no_library(self) -> None: """Test Markdown extraction without markdown library""" - with patch('builtins.open', mock_open(read_data="# Header\nContent")): + with patch("builtins.open", mock_open(read_data="# Header\nContent")): result = self.processor._extract_markdown("test.md") - + # Should fallback to plain text assert result == "# Header\nContent" - - @patch('signalwire.search.document_processor.markdown') + + @patch("signalwire.search.document_processor.markdown") def test_extract_markdown_success(self, mock_markdown: MagicMock) -> None: """Test successful Markdown extraction""" mock_markdown.markdown.return_value = "

Header

Content

" - - with patch('builtins.open', mock_open(read_data="# Header\nContent")): - with patch('signalwire.search.document_processor.BeautifulSoup') as mock_bs: - mock_soup = Mock() - mock_soup.get_text.return_value = "Header Content" - mock_bs.return_value = mock_soup - - result = self.processor._extract_markdown("test.md") - - assert result == "Header Content" + + with ( + patch("builtins.open", mock_open(read_data="# Header\nContent")), + patch("signalwire.search.document_processor.BeautifulSoup") as mock_bs, + ): + mock_soup = Mock() + mock_soup.get_text.return_value = "Header Content" + mock_bs.return_value = mock_soup + + result = self.processor._extract_markdown("test.md") + + assert result == "Header Content" class TestDocumentProcessorUtilities: """Test utility methods""" - + def setup_method(self) -> None: """Set up test fixtures""" self.processor = DocumentProcessor() - + def test_create_chunk_basic(self) -> None: """Test basic chunk creation""" chunk = self.processor._create_chunk( - content="Test content", - filename="test.txt", - section="Section 1" + content="Test content", filename="test.txt", section="Section 1" ) - - assert chunk['content'] == "Test content" - assert chunk['filename'] == "test.txt" - assert chunk['section'] == "Section 1" - assert 'metadata' in chunk - assert chunk['metadata']['file_type'] == 'txt' - assert chunk['metadata']['chunk_size'] == len("Test content") - assert chunk['metadata']['word_count'] == 2 - + + assert chunk["content"] == "Test content" + assert chunk["filename"] == "test.txt" + assert chunk["section"] == "Section 1" + assert "metadata" in chunk + assert chunk["metadata"]["file_type"] == "txt" + assert chunk["metadata"]["chunk_size"] == len("Test content") + assert chunk["metadata"]["word_count"] == 2 + def test_create_chunk_with_metadata(self) -> None: """Test chunk creation with custom metadata""" custom_metadata = {"custom_field": "custom_value"} - + chunk = self.processor._create_chunk( - content="Test content", - filename="test.txt", - metadata=custom_metadata + content="Test content", filename="test.txt", metadata=custom_metadata ) - - assert chunk['metadata']['custom_field'] == "custom_value" - assert chunk['metadata']['file_type'] == 'txt' # Base metadata should still be there - - @patch('signalwire.search.document_processor.sent_tokenize') - def test_create_chunk_sentence_count_with_nltk(self, mock_sent_tokenize: MagicMock) -> None: + + assert chunk["metadata"]["custom_field"] == "custom_value" + assert ( + chunk["metadata"]["file_type"] == "txt" + ) # Base metadata should still be there + + @patch("signalwire.search.document_processor.sent_tokenize") + def test_create_chunk_sentence_count_with_nltk( + self, mock_sent_tokenize: MagicMock + ) -> None: """Test chunk creation with NLTK sentence tokenization""" mock_sent_tokenize.return_value = ["Sentence 1.", "Sentence 2."] - + chunk = self.processor._create_chunk( - content="Sentence 1. Sentence 2.", - filename="test.txt" + content="Sentence 1. Sentence 2.", filename="test.txt" ) - - assert chunk['metadata']['sentence_count'] == 2 + + assert chunk["metadata"]["sentence_count"] == 2 mock_sent_tokenize.assert_called_once_with("Sentence 1. Sentence 2.") - - @patch('signalwire.search.document_processor.sent_tokenize', None) + + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_create_chunk_sentence_count_fallback(self) -> None: """Test chunk creation with fallback sentence counting""" chunk = self.processor._create_chunk( - content="Sentence 1. Sentence 2. Sentence 3.", - filename="test.txt" + content="Sentence 1. Sentence 2. Sentence 3.", filename="test.txt" ) - - assert chunk['metadata']['sentence_count'] == 3 - - @patch('signalwire.search.document_processor.sent_tokenize') - def test_create_chunk_sentence_count_error(self, mock_sent_tokenize: MagicMock) -> None: + + assert chunk["metadata"]["sentence_count"] == 3 + + @patch("signalwire.search.document_processor.sent_tokenize") + def test_create_chunk_sentence_count_error( + self, mock_sent_tokenize: MagicMock + ) -> None: """Test chunk creation with sentence counting error""" mock_sent_tokenize.side_effect = Exception("NLTK error") - + chunk = self.processor._create_chunk( - content="Sentence 1. Sentence 2.", - filename="test.txt" + content="Sentence 1. Sentence 2.", filename="test.txt" ) - + # Should fallback to period counting - assert chunk['metadata']['sentence_count'] == 2 - + assert chunk["metadata"]["sentence_count"] == 2 + def test_find_best_split_point_with_empty_lines(self) -> None: """Test finding best split point with paragraph boundaries""" lines = ["line1", "line2", "", "line4", "line5", "line6"] - + split_point = self.processor._find_best_split_point(lines) - + # The algorithm searches backwards from the end in the last 25% of the chunk # With 6 lines, it starts searching from line 4 (3//4 * 6 = 4) backwards # It should find the empty line at index 2, but since it's outside the search range, # it will return the 75% point which is max(1, 6 * 3 // 4) = 4 assert split_point == 4 - + def test_find_best_split_point_no_empty_lines(self) -> None: """Test finding best split point without paragraph boundaries""" lines = ["line1", "line2", "line3", "line4", "line5", "line6"] - + split_point = self.processor._find_best_split_point(lines) - + # Should split at 75% of chunk size expected = max(1, len(lines) * 3 // 4) assert split_point == expected - + def test_get_overlap_lines_basic(self) -> None: """Test getting overlap lines""" - processor = DocumentProcessor(chunk_overlap=50) # 50 characters - large enough to capture lines + processor = DocumentProcessor( + chunk_overlap=50 + ) # 50 characters - large enough to capture lines lines = ["short", "medium line", "longer line here"] - + overlap = processor._get_overlap_lines(lines) - + # Should include lines that fit within overlap size assert len(overlap) > 0 assert all(isinstance(line, str) for line in overlap) - + def test_get_overlap_lines_empty(self) -> None: """Test getting overlap lines with empty input""" overlap = self.processor._get_overlap_lines([]) - + assert overlap == [] class TestDocumentProcessorEdgeCases: """Test edge cases and error handling""" - + def test_chunking_with_empty_content(self) -> None: """Test chunking with empty content""" processor = DocumentProcessor() - + chunks = processor.create_chunks("", "test.txt", "txt") - + # Should handle empty content gracefully assert isinstance(chunks, list) - + def test_chunking_with_whitespace_only(self) -> None: """Test chunking with whitespace-only content""" processor = DocumentProcessor() - + chunks = processor.create_chunks(" \n\n ", "test.txt", "txt") - + # Should handle whitespace-only content gracefully assert isinstance(chunks, list) - + def test_sliding_window_with_small_content(self) -> None: """Test sliding window with content smaller than chunk size""" - processor = DocumentProcessor(chunking_strategy='sliding', chunk_size=10, chunk_overlap=2) - + processor = DocumentProcessor( + chunking_strategy="sliding", chunk_size=10, chunk_overlap=2 + ) + chunks = processor.create_chunks("small", "test.txt", "txt") - + assert len(chunks) == 1 - assert chunks[0]['content'] == "small" - + assert chunks[0]["content"] == "small" + def test_paragraph_chunking_no_paragraphs(self) -> None: """Test paragraph chunking with no paragraph breaks""" - processor = DocumentProcessor(chunking_strategy='paragraph') - + processor = DocumentProcessor(chunking_strategy="paragraph") + chunks = processor.create_chunks("Single line of text", "test.txt", "txt") - + assert len(chunks) == 1 - assert chunks[0]['content'] == "Single line of text" - + assert chunks[0]["content"] == "Single line of text" + def test_page_chunking_empty_pages(self) -> None: """Test page chunking with empty pages""" - processor = DocumentProcessor(chunking_strategy='page') - + processor = DocumentProcessor(chunking_strategy="page") + chunks = processor.create_chunks(["", " ", "Content", ""], "test.txt", "txt") # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch - + assert len(chunks) == 1 - assert chunks[0]['content'] == "Content" + assert chunks[0]["content"] == "Content" # ──────────────────────────────────────────────────────────────────── @@ -711,7 +752,6 @@ def test_page_chunking_empty_pages(self) -> None: # ──────────────────────────────────────────────────────────────────── import json -import re class TestFileExtraction: @@ -722,12 +762,15 @@ def setup_method(self) -> None: # ── PDF ────────────────────────────────────────────────────────── - @patch('signalwire.search.document_processor.pdfplumber') + @patch("signalwire.search.document_processor.pdfplumber") def test_extract_pdf_with_pages(self, mock_pdfplumber: MagicMock) -> None: """Extract text from multiple PDF pages, including one with None text.""" - page1 = Mock(); page1.extract_text.return_value = "Page one text" - page2 = Mock(); page2.extract_text.return_value = None # empty page - page3 = Mock(); page3.extract_text.return_value = "Page three text" + page1 = Mock() + page1.extract_text.return_value = "Page one text" + page2 = Mock() + page2.extract_text.return_value = None # empty page + page3 = Mock() + page3.extract_text.return_value = "Page three text" mock_pdf = MagicMock() mock_pdf.pages = [page1, page2, page3] @@ -739,23 +782,27 @@ def test_extract_pdf_with_pages(self, mock_pdfplumber: MagicMock) -> None: assert len(result) == 2 # page2 had None text, should be skipped assert "Page one text" in result[0] - @patch('signalwire.search.document_processor.pdfplumber') - def test_extract_pdf_strips_leading_page_numbers(self, mock_pdfplumber: MagicMock) -> None: + @patch("signalwire.search.document_processor.pdfplumber") + def test_extract_pdf_strips_leading_page_numbers( + self, mock_pdfplumber: MagicMock + ) -> None: """Leading page numbers like '1. ' should be stripped.""" - page = Mock(); page.extract_text.return_value = "1. Introduction paragraph" - mock_pdf = MagicMock(); mock_pdf.pages = [page] + page = Mock() + page.extract_text.return_value = "1. Introduction paragraph" + mock_pdf = MagicMock() + mock_pdf.pages = [page] mock_pdfplumber.open.return_value.__enter__ = Mock(return_value=mock_pdf) mock_pdfplumber.open.return_value.__exit__ = Mock(return_value=False) result = self.processor._extract_pdf("/fake/doc.pdf") assert result == ["Introduction paragraph"] - @patch('signalwire.search.document_processor.pdfplumber', None) + @patch("signalwire.search.document_processor.pdfplumber", None) def test_extract_pdf_missing_dependency(self) -> None: result = self.processor._extract_pdf("/fake/doc.pdf") assert "pdfplumber not available" in result - @patch('signalwire.search.document_processor.pdfplumber') + @patch("signalwire.search.document_processor.pdfplumber") def test_extract_pdf_exception(self, mock_pdfplumber: MagicMock) -> None: mock_pdfplumber.open.side_effect = RuntimeError("corrupt file") result = self.processor._extract_pdf("/fake/doc.pdf") @@ -763,21 +810,26 @@ def test_extract_pdf_exception(self, mock_pdfplumber: MagicMock) -> None: # ── DOCX ───────────────────────────────────────────────────────── - @patch('signalwire.search.document_processor.DocxDocument') - def test_extract_docx_filters_empty_paragraphs(self, mock_docx_cls: MagicMock) -> None: - p1 = Mock(text="Hello"); p2 = Mock(text=""); p3 = Mock(text="World") - mock_doc = Mock(); mock_doc.paragraphs = [p1, p2, p3] + @patch("signalwire.search.document_processor.DocxDocument") + def test_extract_docx_filters_empty_paragraphs( + self, mock_docx_cls: MagicMock + ) -> None: + p1 = Mock(text="Hello") + p2 = Mock(text="") + p3 = Mock(text="World") + mock_doc = Mock() + mock_doc.paragraphs = [p1, p2, p3] mock_docx_cls.return_value = mock_doc result = self.processor._extract_docx("/fake/doc.docx") assert result == ["Hello", "World"] - @patch('signalwire.search.document_processor.DocxDocument', None) + @patch("signalwire.search.document_processor.DocxDocument", None) def test_extract_docx_missing_dependency(self) -> None: result = self.processor._extract_docx("/fake/doc.docx") assert "python-docx not available" in result - @patch('signalwire.search.document_processor.DocxDocument') + @patch("signalwire.search.document_processor.DocxDocument") def test_extract_docx_exception(self, mock_docx_cls: MagicMock) -> None: mock_docx_cls.side_effect = ValueError("bad docx") result = self.processor._extract_docx("/fake/doc.docx") @@ -785,7 +837,7 @@ def test_extract_docx_exception(self, mock_docx_cls: MagicMock) -> None: # ── XLSX (Excel) ───────────────────────────────────────────────── - @patch('signalwire.search.document_processor.load_workbook') + @patch("signalwire.search.document_processor.load_workbook") def test_extract_excel_success(self, mock_lwb: MagicMock) -> None: mock_sheet = Mock() mock_sheet.iter_rows.return_value = [ @@ -793,7 +845,8 @@ def test_extract_excel_success(self, mock_lwb: MagicMock) -> None: ("Alice", 30), (None, "Bob"), ] - mock_wb = Mock(); mock_wb.worksheets = [mock_sheet] + mock_wb = Mock() + mock_wb.worksheets = [mock_sheet] mock_lwb.return_value = mock_wb result = self.processor._extract_excel("/fake/data.xlsx") @@ -802,12 +855,12 @@ def test_extract_excel_success(self, mock_lwb: MagicMock) -> None: assert "30" in result # integers become str assert "Bob" in result - @patch('signalwire.search.document_processor.load_workbook', None) + @patch("signalwire.search.document_processor.load_workbook", None) def test_extract_excel_missing_dependency(self) -> None: result = self.processor._extract_excel("/fake/data.xlsx") assert "openpyxl not available" in result - @patch('signalwire.search.document_processor.load_workbook') + @patch("signalwire.search.document_processor.load_workbook") def test_extract_excel_exception(self, mock_lwb: MagicMock) -> None: mock_lwb.side_effect = Exception("xlsx error") result = self.processor._extract_excel("/fake/data.xlsx") @@ -815,14 +868,18 @@ def test_extract_excel_exception(self, mock_lwb: MagicMock) -> None: # ── PPTX (PowerPoint) ──────────────────────────────────────────── - @patch('signalwire.search.document_processor.Presentation') + @patch("signalwire.search.document_processor.Presentation") def test_extract_powerpoint_success(self, mock_pres_cls: MagicMock) -> None: - shape1 = Mock(text="Title Slide"); shape1.__class__ = type('S', (), {'text': 'Title Slide'}) + shape1 = Mock(text="Title Slide") + shape1.__class__ = type("S", (), {"text": "Title Slide"}) shape2 = Mock(text="Bullet 1") shape_no_text = Mock(spec=[]) # no 'text' attribute - slide1 = Mock(); slide1.shapes = [shape1, shape_no_text] - slide2 = Mock(); slide2.shapes = [shape2] - mock_pres = Mock(); mock_pres.slides = [slide1, slide2] + slide1 = Mock() + slide1.shapes = [shape1, shape_no_text] + slide2 = Mock() + slide2.shapes = [shape2] + mock_pres = Mock() + mock_pres.slides = [slide1, slide2] mock_pres_cls.return_value = mock_pres result = self.processor._extract_powerpoint("/fake/pres.pptx") @@ -831,12 +888,12 @@ def test_extract_powerpoint_success(self, mock_pres_cls: MagicMock) -> None: assert "Title Slide" in result[0] assert "Bullet 1" in result[1] - @patch('signalwire.search.document_processor.Presentation', None) + @patch("signalwire.search.document_processor.Presentation", None) def test_extract_powerpoint_missing_dependency(self) -> None: result = self.processor._extract_powerpoint("/fake/pres.pptx") assert "python-pptx not available" in result - @patch('signalwire.search.document_processor.Presentation') + @patch("signalwire.search.document_processor.Presentation") def test_extract_powerpoint_exception(self, mock_pres_cls: MagicMock) -> None: mock_pres_cls.side_effect = Exception("pptx error") result = self.processor._extract_powerpoint("/fake/pres.pptx") @@ -844,138 +901,153 @@ def test_extract_powerpoint_exception(self, mock_pres_cls: MagicMock) -> None: # ── HTML ───────────────────────────────────────────────────────── - @patch('signalwire.search.document_processor.BeautifulSoup') + @patch("signalwire.search.document_processor.BeautifulSoup") def test_extract_html_success(self, mock_bs: MagicMock) -> None: - mock_soup = Mock(); mock_soup.get_text.return_value = "Hello World" + mock_soup = Mock() + mock_soup.get_text.return_value = "Hello World" mock_bs.return_value = mock_soup - with patch('builtins.open', mock_open(read_data="

Hello World

")): + with patch("builtins.open", mock_open(read_data="

Hello World

")): result = self.processor._extract_html("/fake/page.html") assert result == "Hello World" - @patch('signalwire.search.document_processor.BeautifulSoup', None) + @patch("signalwire.search.document_processor.BeautifulSoup", None) def test_extract_html_missing_dependency(self) -> None: result = self.processor._extract_html("/fake/page.html") assert "beautifulsoup4 not available" in result - @patch('signalwire.search.document_processor.BeautifulSoup') + @patch("signalwire.search.document_processor.BeautifulSoup") def test_extract_html_exception(self, mock_bs: MagicMock) -> None: - with patch('builtins.open', side_effect=IOError("disk")): + with patch("builtins.open", side_effect=OSError("disk")): result = self.processor._extract_html("/fake/page.html") assert "Error processing HTML" in result # ── Markdown ───────────────────────────────────────────────────── - @patch('signalwire.search.document_processor.BeautifulSoup') - @patch('signalwire.search.document_processor.markdown') - def test_extract_markdown_success(self, mock_md: MagicMock, mock_bs: MagicMock) -> None: + @patch("signalwire.search.document_processor.BeautifulSoup") + @patch("signalwire.search.document_processor.markdown") + def test_extract_markdown_success( + self, mock_md: MagicMock, mock_bs: MagicMock + ) -> None: mock_md.markdown.return_value = "

Title

" - mock_soup = Mock(); mock_soup.get_text.return_value = "Title" + mock_soup = Mock() + mock_soup.get_text.return_value = "Title" mock_bs.return_value = mock_soup - with patch('builtins.open', mock_open(read_data="# Title")): + with patch("builtins.open", mock_open(read_data="# Title")): result = self.processor._extract_markdown("/fake/doc.md") assert result == "Title" - @patch('signalwire.search.document_processor.BeautifulSoup', None) - @patch('signalwire.search.document_processor.markdown', None) + @patch("signalwire.search.document_processor.BeautifulSoup", None) + @patch("signalwire.search.document_processor.markdown", None) def test_extract_markdown_fallback_raw(self) -> None: """Without markdown+BS4, returns raw markdown text.""" - with patch('builtins.open', mock_open(read_data="# Raw Title\nBody")): + with patch("builtins.open", mock_open(read_data="# Raw Title\nBody")): result = self.processor._extract_markdown("/fake/doc.md") assert result == "# Raw Title\nBody" def test_extract_markdown_io_error(self) -> None: - with patch('builtins.open', side_effect=OSError("nope")): + with patch("builtins.open", side_effect=OSError("nope")): result = self.processor._extract_markdown("/fake/doc.md") assert "Error processing Markdown" in result # ── RTF ─────────────────────────────────────────────────────────── - @patch('signalwire.search.document_processor.rtf_to_text') + @patch("signalwire.search.document_processor.rtf_to_text") def test_extract_rtf_success(self, mock_rtf: MagicMock) -> None: mock_rtf.return_value = "Plain text from RTF" - with patch('builtins.open', mock_open(read_data=r"{\rtf1 Plain text from RTF}")): + with patch( + "builtins.open", mock_open(read_data=r"{\rtf1 Plain text from RTF}") + ): result = self.processor._extract_rtf("/fake/doc.rtf") assert result == "Plain text from RTF" - @patch('signalwire.search.document_processor.rtf_to_text', None) + @patch("signalwire.search.document_processor.rtf_to_text", None) def test_extract_rtf_missing_dependency(self) -> None: result = self.processor._extract_rtf("/fake/doc.rtf") assert "striprtf not available" in result - @patch('signalwire.search.document_processor.rtf_to_text') + @patch("signalwire.search.document_processor.rtf_to_text") def test_extract_rtf_exception(self, mock_rtf: MagicMock) -> None: - with patch('builtins.open', side_effect=IOError("disk")): + with patch("builtins.open", side_effect=OSError("disk")): result = self.processor._extract_rtf("/fake/doc.rtf") assert "Error processing RTF" in result # ── Plain text ──────────────────────────────────────────────────── def test_extract_text_success(self) -> None: - with patch('builtins.open', mock_open(read_data="hello world")): + with patch("builtins.open", mock_open(read_data="hello world")): result = self.processor._extract_text("/fake/file.txt") assert result == "hello world" def test_extract_text_encoding_error(self) -> None: - with patch('builtins.open', side_effect=UnicodeDecodeError('utf-8', b'', 0, 1, 'bad')): + with patch( + "builtins.open", side_effect=UnicodeDecodeError("utf-8", b"", 0, 1, "bad") + ): result = self.processor._extract_text("/fake/file.txt") assert "Error processing TXT" in result # ── _extract_text_from_file routing ────────────────────────────── - @patch('signalwire.search.document_processor.magic', None) + @patch("signalwire.search.document_processor.magic", None) def test_extract_text_from_file_routes_xlsx(self) -> None: - with patch.object(self.processor, '_extract_excel', return_value="cells") as m: + with patch.object(self.processor, "_extract_excel", return_value="cells") as m: result = self.processor._extract_text_from_file("data.xlsx") m.assert_called_once_with("data.xlsx") assert result == "cells" - @patch('signalwire.search.document_processor.magic', None) + @patch("signalwire.search.document_processor.magic", None) def test_extract_text_from_file_routes_pptx(self) -> None: - with patch.object(self.processor, '_extract_powerpoint', return_value=["s1"]) as m: + with patch.object( + self.processor, "_extract_powerpoint", return_value=["s1"] + ) as m: result = self.processor._extract_text_from_file("slides.pptx") m.assert_called_once_with("slides.pptx") assert result == ["s1"] - @patch('signalwire.search.document_processor.magic', None) + @patch("signalwire.search.document_processor.magic", None) def test_extract_text_from_file_routes_rtf(self) -> None: - with patch.object(self.processor, '_extract_rtf', return_value="rtf text") as m: + with patch.object(self.processor, "_extract_rtf", return_value="rtf text") as m: result = self.processor._extract_text_from_file("notes.rtf") m.assert_called_once_with("notes.rtf") assert result == "rtf text" - @patch('signalwire.search.document_processor.magic', None) + @patch("signalwire.search.document_processor.magic", None) def test_extract_text_from_file_unknown_extension(self) -> None: """Unknown extension falls back to text/plain routing.""" - with patch.object(self.processor, '_extract_text', return_value="raw") as m: + with patch.object(self.processor, "_extract_text", return_value="raw") as m: result = self.processor._extract_text_from_file("file.zzz") m.assert_called_once_with("file.zzz") assert result == "raw" - @patch('signalwire.search.document_processor.magic') + @patch("signalwire.search.document_processor.magic") def test_extract_text_from_file_magic_html(self, mock_magic: MagicMock) -> None: """When magic reports text/html, route to _extract_html.""" - mock_mime = Mock(); mock_mime.from_file.return_value = "text/html" + mock_mime = Mock() + mock_mime.from_file.return_value = "text/html" mock_magic.Magic.return_value = mock_mime # 'html' in 'text/html' is True, but 'plain' in 'text/html' is False, # and 'text' in 'text/html' is True -> goes to _extract_text first # Actually 'plain' in 'text/html' is False but 'text' in 'text/html' is True # So it hits the 'plain' or 'text' branch -> _extract_text - with patch.object(self.processor, '_extract_text', return_value="content") as m: + with patch.object(self.processor, "_extract_text", return_value="content"): result = self.processor._extract_text_from_file("page.html") assert result == "content" - @patch('signalwire.search.document_processor.magic') + @patch("signalwire.search.document_processor.magic") def test_extract_text_from_file_magic_rtf(self, mock_magic: MagicMock) -> None: - mock_mime = Mock(); mock_mime.from_file.return_value = "application/rtf" + mock_mime = Mock() + mock_mime.from_file.return_value = "application/rtf" mock_magic.Magic.return_value = mock_mime - with patch.object(self.processor, '_extract_rtf', return_value="rtf") as m: + with patch.object(self.processor, "_extract_rtf", return_value="rtf") as m: result = self.processor._extract_text_from_file("doc.rtf") m.assert_called_once_with("doc.rtf") assert result == "rtf" - @patch('signalwire.search.document_processor.magic') - def test_extract_text_from_file_magic_unsupported(self, mock_magic: MagicMock) -> None: - mock_mime = Mock(); mock_mime.from_file.return_value = "application/octet-stream" + @patch("signalwire.search.document_processor.magic") + def test_extract_text_from_file_magic_unsupported( + self, mock_magic: MagicMock + ) -> None: + mock_mime = Mock() + mock_mime.from_file.return_value = "application/octet-stream" mock_magic.Magic.return_value = mock_mime result = self.processor._extract_text_from_file("binary.bin") assert "Unsupported file type" in result @@ -986,130 +1058,149 @@ class TestChunkingStrategies: # ── sentence ───────────────────────────────────────────────────── - @patch('signalwire.search.document_processor.sent_tokenize', None) + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_sentence_chunking_no_nltk(self) -> None: """Without NLTK, fallback to period-based splitting.""" - proc = DocumentProcessor(chunking_strategy='sentence', max_sentences_per_chunk=2) + proc = DocumentProcessor( + chunking_strategy="sentence", max_sentences_per_chunk=2 + ) text = "Alpha. Beta. Gamma. Delta." chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) >= 1 - assert all(c['metadata']['chunk_method'] == 'sentence_based' for c in chunks) + assert all(c["metadata"]["chunk_method"] == "sentence_based" for c in chunks) - @patch('signalwire.search.document_processor.sent_tokenize') + @patch("signalwire.search.document_processor.sent_tokenize") def test_sentence_chunking_with_nltk(self, mock_tok: MagicMock) -> None: - mock_tok.side_effect = lambda t: [s.strip() for s in t.split('.') if s.strip()] - proc = DocumentProcessor(chunking_strategy='sentence', max_sentences_per_chunk=2) + mock_tok.side_effect = lambda t: [s.strip() for s in t.split(".") if s.strip()] + proc = DocumentProcessor( + chunking_strategy="sentence", max_sentences_per_chunk=2 + ) text = "One. Two. Three. Four." chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) >= 1 - @patch('signalwire.search.document_processor.sent_tokenize') - def test_sentence_chunking_with_split_newlines_zero(self, mock_tok: MagicMock) -> None: + @patch("signalwire.search.document_processor.sent_tokenize") + def test_sentence_chunking_with_split_newlines_zero( + self, mock_tok: MagicMock + ) -> None: """split_newlines=0 should use direct tokenization without newline splitting.""" - mock_tok.side_effect = lambda t: [s.strip() for s in t.split('.') if s.strip()] - proc = DocumentProcessor(chunking_strategy='sentence', max_sentences_per_chunk=3, split_newlines=0) + mock_tok.side_effect = lambda t: [s.strip() for s in t.split(".") if s.strip()] + proc = DocumentProcessor( + chunking_strategy="sentence", max_sentences_per_chunk=3, split_newlines=0 + ) text = "Hello. World." chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) >= 1 def test_sentence_chunking_list_input(self) -> None: - proc = DocumentProcessor(chunking_strategy='sentence', max_sentences_per_chunk=3) + proc = DocumentProcessor( + chunking_strategy="sentence", max_sentences_per_chunk=3 + ) chunks = proc.create_chunks(["Line A.", "Line B.", "Line C."], "f.txt", "txt") # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch assert len(chunks) >= 1 # ── sliding_window ─────────────────────────────────────────────── def test_sliding_window_basic(self) -> None: - proc = DocumentProcessor(chunking_strategy='sliding', chunk_size=3, chunk_overlap=1) + proc = DocumentProcessor( + chunking_strategy="sliding", chunk_size=3, chunk_overlap=1 + ) chunks = proc.create_chunks("a b c d e f g h", "f.txt", "txt") assert len(chunks) >= 2 - assert all(c['metadata']['chunk_method'] == 'sliding_window' for c in chunks) + assert all(c["metadata"]["chunk_method"] == "sliding_window" for c in chunks) def test_sliding_window_list_input(self) -> None: - proc = DocumentProcessor(chunking_strategy='sliding', chunk_size=4, chunk_overlap=1) - chunks = proc.create_chunks(["word1 word2", "word3 word4 word5"], "f.txt", "txt") # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch + proc = DocumentProcessor( + chunking_strategy="sliding", chunk_size=4, chunk_overlap=1 + ) + words = ["word1 word2", "word3 word4 word5"] + chunks = proc.create_chunks(words, "f.txt", "txt") # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch assert len(chunks) >= 1 def test_sliding_window_empty(self) -> None: - proc = DocumentProcessor(chunking_strategy='sliding', chunk_size=5, chunk_overlap=2) + proc = DocumentProcessor( + chunking_strategy="sliding", chunk_size=5, chunk_overlap=2 + ) chunks = proc.create_chunks("", "f.txt", "txt") assert chunks == [] def test_sliding_window_overlap_metadata(self) -> None: - proc = DocumentProcessor(chunking_strategy='sliding', chunk_size=3, chunk_overlap=1) + proc = DocumentProcessor( + chunking_strategy="sliding", chunk_size=3, chunk_overlap=1 + ) chunks = proc.create_chunks("w1 w2 w3 w4 w5 w6", "f.txt", "txt") - assert chunks[0]['metadata']['start_word'] == 0 - assert chunks[0]['metadata']['end_word'] == 3 + assert chunks[0]["metadata"]["start_word"] == 0 + assert chunks[0]["metadata"]["end_word"] == 3 # ── paragraphs ─────────────────────────────────────────────────── def test_paragraphs_basic(self) -> None: - proc = DocumentProcessor(chunking_strategy='paragraph') + proc = DocumentProcessor(chunking_strategy="paragraph") text = "Para one.\n\nPara two.\n\nPara three." chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) == 3 - assert chunks[0]['content'] == "Para one." - assert all(c['metadata']['chunk_method'] == 'paragraph_based' for c in chunks) + assert chunks[0]["content"] == "Para one." + assert all(c["metadata"]["chunk_method"] == "paragraph_based" for c in chunks) def test_paragraphs_single_paragraph(self) -> None: - proc = DocumentProcessor(chunking_strategy='paragraph') + proc = DocumentProcessor(chunking_strategy="paragraph") chunks = proc.create_chunks("Just one paragraph.", "f.txt", "txt") assert len(chunks) == 1 def test_paragraphs_list_input(self) -> None: - proc = DocumentProcessor(chunking_strategy='paragraph') + proc = DocumentProcessor(chunking_strategy="paragraph") chunks = proc.create_chunks(["line1", "", "line3"], "f.txt", "txt") # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch assert len(chunks) >= 1 def test_paragraphs_metadata(self) -> None: - proc = DocumentProcessor(chunking_strategy='paragraph') + proc = DocumentProcessor(chunking_strategy="paragraph") chunks = proc.create_chunks("A.\n\nB.", "f.txt", "txt") - assert chunks[0]['metadata']['paragraph_number'] == 1 - assert chunks[1]['metadata']['paragraph_number'] == 2 + assert chunks[0]["metadata"]["paragraph_number"] == 1 + assert chunks[1]["metadata"]["paragraph_number"] == 2 # ── pages ──────────────────────────────────────────────────────── def test_pages_list_input(self) -> None: - proc = DocumentProcessor(chunking_strategy='page') + proc = DocumentProcessor(chunking_strategy="page") chunks = proc.create_chunks(["P1", "P2"], "f.pdf", "pdf") # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch assert len(chunks) == 2 - assert chunks[0]['metadata']['page_number'] == 1 + assert chunks[0]["metadata"]["page_number"] == 1 def test_pages_form_feed(self) -> None: - proc = DocumentProcessor(chunking_strategy='page') + proc = DocumentProcessor(chunking_strategy="page") chunks = proc.create_chunks("A\fB\fC", "f.txt", "txt") assert len(chunks) == 3 def test_pages_page_markers(self) -> None: - proc = DocumentProcessor(chunking_strategy='page') + proc = DocumentProcessor(chunking_strategy="page") chunks = proc.create_chunks("X---PAGE---Y", "f.txt", "txt") assert len(chunks) == 2 def test_pages_page_number_pattern(self) -> None: - proc = DocumentProcessor(chunking_strategy='page') + proc = DocumentProcessor(chunking_strategy="page") text = "Content A\n Page 1 \nContent B\n Page 2 \nContent C" chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) >= 2 def test_pages_fallback_large_text(self) -> None: """Plain text without markers should fall back to word-based splitting.""" - proc = DocumentProcessor(chunking_strategy='page') + proc = DocumentProcessor(chunking_strategy="page") words = " ".join(["word"] * 2000) chunks = proc.create_chunks(words, "f.txt", "txt") assert len(chunks) >= 2 # ── semantic ───────────────────────────────────────────────────── - @patch('signalwire.search.document_processor.sent_tokenize', None) + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_semantic_single_sentence_fallback(self) -> None: """Single sentence should return one chunk without import error.""" - proc = DocumentProcessor(chunking_strategy='semantic') + proc = DocumentProcessor(chunking_strategy="semantic") chunks = proc.create_chunks("Only one sentence.", "f.txt", "txt") assert len(chunks) == 1 - assert chunks[0]['metadata']['chunk_method'] == 'semantic' + assert chunks[0]["metadata"]["chunk_method"] == "semantic" - @patch('signalwire.search.query_processor._get_cached_model', return_value=None) - @patch('signalwire.search.document_processor.sent_tokenize', None) + @patch("signalwire.search.query_processor._get_cached_model", return_value=None) + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_semantic_import_error_falls_back(self, _mock_model: MagicMock) -> None: """When the embedding model is unavailable, fall back to sentence-based. @@ -1120,41 +1211,45 @@ def test_semantic_import_error_falls_back(self, _mock_model: MagicMock) -> None: model.encode(None); it must now degrade to sentence chunking regardless of whether the model is actually installed. """ - proc = DocumentProcessor(chunking_strategy='semantic') + proc = DocumentProcessor(chunking_strategy="semantic") text = "Hello there. How are you. I am fine. Thanks for asking." chunks = proc.create_chunks(text, "f.txt", "txt") # Should get chunks (via fallback), not a crash. assert len(chunks) >= 1 - @patch('signalwire.search.query_processor._get_cached_model', return_value=None) - @patch('signalwire.search.document_processor.sent_tokenize') - def test_semantic_with_tokenizer_import_error(self, mock_tok: MagicMock, _mock_model: MagicMock) -> None: + @patch("signalwire.search.query_processor._get_cached_model", return_value=None) + @patch("signalwire.search.document_processor.sent_tokenize") + def test_semantic_with_tokenizer_import_error( + self, mock_tok: MagicMock, _mock_model: MagicMock + ) -> None: """Semantic with NLTK available but the embedding model unavailable. Same deterministic forcing of model-None as above, with NLTK's sent_tokenize mocked in so the sentence-splitting path is exercised too. """ - mock_tok.side_effect = lambda t: [s.strip()+'.' for s in t.split('.') if s.strip()] - proc = DocumentProcessor(chunking_strategy='semantic') + mock_tok.side_effect = lambda t: [ + s.strip() + "." for s in t.split(".") if s.strip() + ] + proc = DocumentProcessor(chunking_strategy="semantic") text = "Alpha sentence. Beta sentence. Gamma sentence. Delta sentence." chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) >= 1 # ── topics ─────────────────────────────────────────────────────── - @patch('signalwire.search.document_processor.sent_tokenize', None) + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_topics_short_text_single_chunk(self) -> None: """3 or fewer sentences returns a single topic chunk.""" - proc = DocumentProcessor(chunking_strategy='topic') + proc = DocumentProcessor(chunking_strategy="topic") text = "Machine learning rocks. Deep learning too." chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) == 1 - assert chunks[0]['metadata']['chunk_method'] == 'topic' + assert chunks[0]["metadata"]["chunk_method"] == "topic" - @patch('signalwire.search.document_processor.sent_tokenize', None) + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_topics_multiple_topics(self) -> None: """Multiple distinct topics should produce multiple chunks.""" - proc = DocumentProcessor(chunking_strategy='topic', topic_threshold=0.9) + proc = DocumentProcessor(chunking_strategy="topic", topic_threshold=0.9) # Completely different keyword sets per sentence text = ( "Python programming language features are impressive. " @@ -1165,30 +1260,36 @@ def test_topics_multiple_topics(self) -> None: ) chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) >= 1 - assert all(c['metadata']['chunk_method'] == 'topic' for c in chunks) + assert all(c["metadata"]["chunk_method"] == "topic" for c in chunks) - @patch('signalwire.search.document_processor.sent_tokenize') + @patch("signalwire.search.document_processor.sent_tokenize") def test_topics_with_nltk(self, mock_tok: MagicMock) -> None: - mock_tok.side_effect = lambda t: [s.strip() for s in t.split('.') if s.strip()] - proc = DocumentProcessor(chunking_strategy='topic', topic_threshold=0.0) + mock_tok.side_effect = lambda t: [s.strip() for s in t.split(".") if s.strip()] + proc = DocumentProcessor(chunking_strategy="topic", topic_threshold=0.0) text = "Alpha beta gamma. Delta epsilon zeta. Eta theta iota. Kappa lambda mu." chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) >= 1 - @patch('signalwire.search.document_processor.sent_tokenize', None) + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_topics_list_input(self) -> None: - proc = DocumentProcessor(chunking_strategy='topic') + proc = DocumentProcessor(chunking_strategy="topic") chunks = proc.create_chunks( - ["First about dogs.", "Second about cats.", "Third about birds.", "Fourth about fish."], # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch - "f.txt", "txt" + [ + "First about dogs.", + "Second about cats.", + "Third about birds.", + "Fourth about fish.", + ], # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch + "f.txt", + "txt", ) assert len(chunks) >= 1 # ── qa_optimization ────────────────────────────────────────────── - @patch('signalwire.search.document_processor.sent_tokenize', None) + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_qa_optimization_basic(self) -> None: - proc = DocumentProcessor(chunking_strategy='qa') + proc = DocumentProcessor(chunking_strategy="qa") text = ( "What is Python? Python is a programming language. " "How does it work? It interprets code. " @@ -1196,73 +1297,82 @@ def test_qa_optimization_basic(self) -> None: ) chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) >= 1 - assert all(c['metadata']['chunk_method'] == 'qa_optimized' for c in chunks) + assert all(c["metadata"]["chunk_method"] == "qa_optimized" for c in chunks) - @patch('signalwire.search.document_processor.sent_tokenize', None) + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_qa_optimization_has_question_metadata(self) -> None: - proc = DocumentProcessor(chunking_strategy='qa') + proc = DocumentProcessor(chunking_strategy="qa") text = "What is this? It is a test. Does it work? Yes it does. Final statement here." chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) >= 1 # At least one chunk should have 'has_question' - has_q_chunks = [c for c in chunks if c['metadata'].get('has_question')] + has_q_chunks = [c for c in chunks if c["metadata"].get("has_question")] assert len(has_q_chunks) >= 0 # may or may not have, but should not crash - @patch('signalwire.search.document_processor.sent_tokenize') + @patch("signalwire.search.document_processor.sent_tokenize") def test_qa_optimization_with_nltk(self, mock_tok: MagicMock) -> None: - mock_tok.side_effect = lambda t: [s.strip() for s in t.split('.') if s.strip()] - proc = DocumentProcessor(chunking_strategy='qa') + mock_tok.side_effect = lambda t: [s.strip() for s in t.split(".") if s.strip()] + proc = DocumentProcessor(chunking_strategy="qa") text = "What is X? X is Y. How about Z? Z is W. Step one. Step two." chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) >= 1 - @patch('signalwire.search.document_processor.sent_tokenize', None) + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_qa_optimization_empty_text(self) -> None: - proc = DocumentProcessor(chunking_strategy='qa') + proc = DocumentProcessor(chunking_strategy="qa") chunks = proc.create_chunks("", "f.txt", "txt") assert isinstance(chunks, list) - @patch('signalwire.search.document_processor.sent_tokenize', None) + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_qa_optimization_list_input(self) -> None: - proc = DocumentProcessor(chunking_strategy='qa') - chunks = proc.create_chunks(["Question? Answer.", "More content."], "f.txt", "txt") # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch + proc = DocumentProcessor(chunking_strategy="qa") + parts = ["Question? Answer.", "More content."] + chunks = proc.create_chunks(parts, "f.txt", "txt") # type: ignore[arg-type] # list[str] input handled by runtime isinstance branch assert len(chunks) >= 1 # ── json ───────────────────────────────────────────────────────── def test_json_chunking_valid(self) -> None: - proc = DocumentProcessor(chunking_strategy='json') + proc = DocumentProcessor(chunking_strategy="json") data = { "chunks": [ - {"chunk_id": "c1", "type": "content", "content": "Hello world", - "metadata": {"section": "Intro", "tags": ["greeting"]}}, - {"chunk_id": "c2", "type": "toc", "content": "Table of Contents item", - "metadata": {}}, + { + "chunk_id": "c1", + "type": "content", + "content": "Hello world", + "metadata": {"section": "Intro", "tags": ["greeting"]}, + }, + { + "chunk_id": "c2", + "type": "toc", + "content": "Table of Contents item", + "metadata": {}, + }, ] } chunks = proc.create_chunks(json.dumps(data), "f.json", "json") assert len(chunks) == 2 - assert chunks[0]['metadata']['chunk_method'] == 'json' - assert chunks[0]['metadata']['original_chunk_id'] == 'c1' - assert chunks[0]['tags'] == ['greeting'] + assert chunks[0]["metadata"]["chunk_method"] == "json" + assert chunks[0]["metadata"]["original_chunk_id"] == "c1" + assert chunks[0]["tags"] == ["greeting"] # TOC entry should get special tags - assert 'toc' in chunks[1]['tags'] + assert "toc" in chunks[1]["tags"] def test_json_chunking_invalid_json(self) -> None: - proc = DocumentProcessor(chunking_strategy='json') + proc = DocumentProcessor(chunking_strategy="json") chunks = proc.create_chunks("not valid json {{{", "f.json", "json") # Should fall back to sentence-based assert len(chunks) >= 1 def test_json_chunking_missing_chunks_key(self) -> None: - proc = DocumentProcessor(chunking_strategy='json') + proc = DocumentProcessor(chunking_strategy="json") data = {"data": [1, 2, 3]} chunks = proc.create_chunks(json.dumps(data), "f.json", "json") # Should fall back to sentence-based assert len(chunks) >= 1 def test_json_chunking_skip_invalid_chunk(self) -> None: - proc = DocumentProcessor(chunking_strategy='json') + proc = DocumentProcessor(chunking_strategy="json") data = { "chunks": [ {"chunk_id": "ok", "content": "Valid"}, @@ -1272,76 +1382,87 @@ def test_json_chunking_skip_invalid_chunk(self) -> None: } chunks = proc.create_chunks(json.dumps(data), "f.json", "json") assert len(chunks) == 1 - assert "Valid" in chunks[0]['content'] + assert "Valid" in chunks[0]["content"] def test_json_chunking_empty_chunks_list(self) -> None: - proc = DocumentProcessor(chunking_strategy='json') + proc = DocumentProcessor(chunking_strategy="json") data: dict[str, list[Any]] = {"chunks": []} chunks = proc.create_chunks(json.dumps(data), "f.json", "json") # Falls back to sentence chunking of empty-ish text assert isinstance(chunks, list) def test_json_chunking_toc_section_naming(self) -> None: - proc = DocumentProcessor(chunking_strategy='json') - data = {"chunks": [ - {"type": "toc", "content": "A very long table of contents entry that should be truncated somehow"}, - ]} + proc = DocumentProcessor(chunking_strategy="json") + data = { + "chunks": [ + { + "type": "toc", + "content": "A very long table of contents entry that should be truncated somehow", + }, + ] + } chunks = proc.create_chunks(json.dumps(data), "f.json", "json") assert len(chunks) == 1 - assert chunks[0]['section'].startswith("TOC:") + assert chunks[0]["section"].startswith("TOC:") def test_json_chunking_content_with_section_number(self) -> None: - proc = DocumentProcessor(chunking_strategy='json') - data = {"chunks": [ - {"type": "content", "content": "Body text", "metadata": {"section_number": 42}}, - ]} + proc = DocumentProcessor(chunking_strategy="json") + data = { + "chunks": [ + { + "type": "content", + "content": "Body text", + "metadata": {"section_number": 42}, + }, + ] + } chunks = proc.create_chunks(json.dumps(data), "f.json", "json") - assert chunks[0]['section'] == "Section 42" + assert chunks[0]["section"] == "Section 42" # ── markdown_enhanced ──────────────────────────────────────────── def test_markdown_enhanced_basic_headers(self) -> None: - proc = DocumentProcessor(chunking_strategy='markdown') + proc = DocumentProcessor(chunking_strategy="markdown") md = "# Title\nSome intro.\n## Section A\nContent A.\n## Section B\nContent B." chunks = proc.create_chunks(md, "doc.md", "md") assert len(chunks) >= 2 # Check section hierarchy - sections = [c['section'] for c in chunks if c['section']] - assert any('Title' in s for s in sections) + sections = [c["section"] for c in chunks if c["section"]] + assert any("Title" in s for s in sections) def test_markdown_enhanced_code_blocks(self) -> None: - proc = DocumentProcessor(chunking_strategy='markdown') + proc = DocumentProcessor(chunking_strategy="markdown") md = "# Code Example\n```python\nprint('hello')\n```\nSome text." chunks = proc.create_chunks(md, "doc.md", "md") assert len(chunks) >= 1 # At least one chunk should have code metadata - code_chunks = [c for c in chunks if c['metadata'].get('has_code')] + code_chunks = [c for c in chunks if c["metadata"].get("has_code")] assert len(code_chunks) >= 1 - assert 'python' in code_chunks[0]['metadata'].get('code_languages', []) + assert "python" in code_chunks[0]["metadata"].get("code_languages", []) def test_markdown_enhanced_nested_headers(self) -> None: - proc = DocumentProcessor(chunking_strategy='markdown') + proc = DocumentProcessor(chunking_strategy="markdown") md = "# H1\n## H2\n### H3\nDeep content." chunks = proc.create_chunks(md, "doc.md", "md") assert len(chunks) >= 1 def test_markdown_enhanced_no_headers(self) -> None: - proc = DocumentProcessor(chunking_strategy='markdown') + proc = DocumentProcessor(chunking_strategy="markdown") md = "Just plain text.\nMore text." chunks = proc.create_chunks(md, "doc.md", "md") assert len(chunks) >= 1 def test_markdown_enhanced_code_without_language(self) -> None: - proc = DocumentProcessor(chunking_strategy='markdown') + proc = DocumentProcessor(chunking_strategy="markdown") md = "# Sec\n```\ngeneric code\n```\nDone." chunks = proc.create_chunks(md, "doc.md", "md") - code_chunks = [c for c in chunks if c['metadata'].get('has_code')] + code_chunks = [c for c in chunks if c["metadata"].get("has_code")] assert len(code_chunks) >= 1 # No language specified -> code_languages should be empty - assert code_chunks[0]['metadata'].get('code_languages', []) == [] + assert code_chunks[0]["metadata"].get("code_languages", []) == [] def test_markdown_enhanced_empty_content(self) -> None: - proc = DocumentProcessor(chunking_strategy='markdown') + proc = DocumentProcessor(chunking_strategy="markdown") chunks = proc.create_chunks("", "doc.md", "md") assert isinstance(chunks, list) @@ -1350,93 +1471,111 @@ class TestEdgeCases: """Edge cases: empty, unicode, very large, unsupported.""" def test_empty_string_all_strategies(self) -> None: - for strategy in ['sentence', 'sliding', 'paragraph', 'page', 'qa', 'json', 'markdown']: + for strategy in [ + "sentence", + "sliding", + "paragraph", + "page", + "qa", + "json", + "markdown", + ]: proc = DocumentProcessor(chunking_strategy=strategy) chunks = proc.create_chunks("", "f.txt", "txt") - assert isinstance(chunks, list), f"Strategy {strategy} failed on empty string" + assert isinstance(chunks, list), ( + f"Strategy {strategy} failed on empty string" + ) def test_whitespace_only_all_strategies(self) -> None: - for strategy in ['sentence', 'sliding', 'paragraph', 'page', 'qa', 'markdown']: + for strategy in ["sentence", "sliding", "paragraph", "page", "qa", "markdown"]: proc = DocumentProcessor(chunking_strategy=strategy) chunks = proc.create_chunks(" \n\t\n ", "f.txt", "txt") assert isinstance(chunks, list), f"Strategy {strategy} failed on whitespace" def test_unicode_content(self) -> None: - proc = DocumentProcessor(chunking_strategy='sentence', max_sentences_per_chunk=2) + proc = DocumentProcessor( + chunking_strategy="sentence", max_sentences_per_chunk=2 + ) text = "日本語のテスト文。これは二番目の文です。三番目の文もあります。" chunks = proc.create_chunks(text, "jp.txt", "txt") assert len(chunks) >= 1 def test_unicode_emoji_content(self) -> None: - proc = DocumentProcessor(chunking_strategy='paragraph') + proc = DocumentProcessor(chunking_strategy="paragraph") text = "First para with emoji.\n\nSecond para." chunks = proc.create_chunks(text, "emoji.txt", "txt") assert len(chunks) == 2 def test_very_large_text_sentence(self) -> None: - proc = DocumentProcessor(chunking_strategy='sentence', max_sentences_per_chunk=5) + proc = DocumentProcessor( + chunking_strategy="sentence", max_sentences_per_chunk=5 + ) text = ". ".join([f"Sentence number {i}" for i in range(200)]) + "." chunks = proc.create_chunks(text, "big.txt", "txt") assert len(chunks) > 10 def test_very_large_text_sliding(self) -> None: - proc = DocumentProcessor(chunking_strategy='sliding', chunk_size=50, chunk_overlap=10) + proc = DocumentProcessor( + chunking_strategy="sliding", chunk_size=50, chunk_overlap=10 + ) text = " ".join([f"word{i}" for i in range(1000)]) chunks = proc.create_chunks(text, "big.txt", "txt") assert len(chunks) > 10 def test_very_large_text_paragraph(self) -> None: - proc = DocumentProcessor(chunking_strategy='paragraph') + proc = DocumentProcessor(chunking_strategy="paragraph") text = "\n\n".join([f"Paragraph {i} content." for i in range(100)]) chunks = proc.create_chunks(text, "big.txt", "txt") assert len(chunks) == 100 def test_single_word(self) -> None: - proc = DocumentProcessor(chunking_strategy='sentence') + proc = DocumentProcessor(chunking_strategy="sentence") chunks = proc.create_chunks("hello", "f.txt", "txt") assert len(chunks) >= 1 - assert "hello" in chunks[0]['content'] + assert "hello" in chunks[0]["content"] def test_single_character(self) -> None: - proc = DocumentProcessor(chunking_strategy='sliding', chunk_size=5, chunk_overlap=1) + proc = DocumentProcessor( + chunking_strategy="sliding", chunk_size=5, chunk_overlap=1 + ) chunks = proc.create_chunks("x", "f.txt", "txt") assert len(chunks) == 1 def test_newlines_only(self) -> None: - proc = DocumentProcessor(chunking_strategy='paragraph') + proc = DocumentProcessor(chunking_strategy="paragraph") chunks = proc.create_chunks("\n\n\n\n", "f.txt", "txt") assert chunks == [] def test_mixed_line_endings(self) -> None: - proc = DocumentProcessor(chunking_strategy='paragraph') + proc = DocumentProcessor(chunking_strategy="paragraph") text = "Para 1.\r\n\r\nPara 2.\r\n\r\nPara 3." chunks = proc.create_chunks(text, "f.txt", "txt") assert len(chunks) == 3 def test_unsupported_chunking_strategy_fallback(self) -> None: - proc = DocumentProcessor(chunking_strategy='nonexistent_strategy') + proc = DocumentProcessor(chunking_strategy="nonexistent_strategy") chunks = proc.create_chunks("Some text here.", "f.txt", "txt") # Fallback to sentence assert len(chunks) >= 1 - assert chunks[0]['metadata']['chunk_method'] == 'sentence_based' + assert chunks[0]["metadata"]["chunk_method"] == "sentence_based" def test_create_chunk_hash_stability(self) -> None: """Same content produces same chunk structure.""" proc = DocumentProcessor() c1 = proc._create_chunk("abc", "f.txt", "S1") c2 = proc._create_chunk("abc", "f.txt", "S1") - assert c1['content'] == c2['content'] - assert c1['metadata']['word_count'] == c2['metadata']['word_count'] + assert c1["content"] == c2["content"] + assert c1["metadata"]["word_count"] == c2["metadata"]["word_count"] def test_create_chunk_filename_extension(self) -> None: proc = DocumentProcessor() chunk = proc._create_chunk("test", "archive.tar.gz") - assert chunk['metadata']['file_type'] == 'gz' + assert chunk["metadata"]["file_type"] == "gz" def test_create_chunk_no_extension(self) -> None: proc = DocumentProcessor() chunk = proc._create_chunk("test", "Makefile") - assert chunk['metadata']['file_type'] == '' + assert chunk["metadata"]["file_type"] == "" # ── _build helpers ─────────────────────────────────────────────── @@ -1455,24 +1594,24 @@ def test_build_section_path_nested(self) -> None: def test_build_markdown_metadata_no_code(self) -> None: proc = DocumentProcessor() meta = proc._build_markdown_metadata(["H1"], [], False) - assert meta['chunk_type'] == 'markdown' - assert meta.get('h1') == 'H1' - assert 'has_code' not in meta + assert meta["chunk_type"] == "markdown" + assert meta.get("h1") == "H1" + assert "has_code" not in meta def test_build_markdown_metadata_with_code(self) -> None: proc = DocumentProcessor() meta = proc._build_markdown_metadata(["H1", "H2"], ["python", "bash"], True) - assert meta['has_code'] is True - assert meta['code_languages'] == ["python", "bash"] - assert 'code' in meta['tags'] - assert 'code:python' in meta['tags'] - assert 'depth:2' in meta['tags'] + assert meta["has_code"] is True + assert meta["code_languages"] == ["python", "bash"] + assert "code" in meta["tags"] + assert "code:python" in meta["tags"] + assert "depth:2" in meta["tags"] def test_build_markdown_metadata_empty_hierarchy(self) -> None: proc = DocumentProcessor() meta = proc._build_markdown_metadata([], [], False) - assert 'h1' not in meta - assert 'tags' not in meta # no code, no hierarchy -> no tags + assert "h1" not in meta + assert "tags" not in meta # no code, no hierarchy -> no tags def test_build_python_section_class_and_function(self) -> None: proc = DocumentProcessor() @@ -1492,14 +1631,14 @@ def test_build_python_section_neither(self) -> None: # ── _calculate_sentences_per_chunk ─────────────────────────────── - @patch('signalwire.search.document_processor.sent_tokenize', None) + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_calculate_sentences_per_chunk_no_nltk(self) -> None: proc = DocumentProcessor(chunk_size=50) result = proc._calculate_sentences_per_chunk("Short. Another.") assert isinstance(result, int) assert result >= 1 - @patch('signalwire.search.document_processor.sent_tokenize', None) + @patch("signalwire.search.document_processor.sent_tokenize", None) def test_calculate_sentences_per_chunk_empty(self) -> None: """Empty string with no nltk causes ZeroDivisionError (source code bug).""" proc = DocumentProcessor() @@ -1531,21 +1670,21 @@ def test_chunk_document_aware_list_small_pages(self) -> None: pages = ["Small page one.", "Small page two."] chunks = proc._chunk_document_aware(pages, "doc.pdf", "pdf") assert len(chunks) == 2 - assert chunks[0]['section'] == "Page 1" + assert chunks[0]["section"] == "Page 1" def test_chunk_document_aware_list_pptx(self) -> None: proc = DocumentProcessor() slides = ["Slide text one.", "Slide text two."] chunks = proc._chunk_document_aware(slides, "pres.pptx", "pptx") assert len(chunks) == 2 - assert chunks[0]['section'] == "Slide 1" + assert chunks[0]["section"] == "Slide 1" def test_chunk_document_aware_list_generic(self) -> None: proc = DocumentProcessor() sections = ["Section one.", "Section two."] chunks = proc._chunk_document_aware(sections, "doc.docx", "docx") assert len(chunks) == 2 - assert chunks[0]['section'] == "Section 1" + assert chunks[0]["section"] == "Section 1" def test_chunk_document_aware_string(self) -> None: proc = DocumentProcessor() @@ -1556,4 +1695,4 @@ def test_chunk_document_aware_skips_empty_pages(self) -> None: proc = DocumentProcessor() pages = ["Content", "", " ", "More content"] chunks = proc._chunk_document_aware(pages, "doc.pdf", "pdf") - assert len(chunks) == 2 \ No newline at end of file + assert len(chunks) == 2 diff --git a/tests/unit/search/test_index_builder.py b/tests/unit/search/test_index_builder.py index f6333401..5de9c509 100644 --- a/tests/unit/search/test_index_builder.py +++ b/tests/unit/search/test_index_builder.py @@ -13,10 +13,10 @@ import pytest import tempfile -import os import sqlite3 -import json -from unittest.mock import Mock, patch, MagicMock, mock_open +from contextlib import closing +from typing import Any +from unittest.mock import Mock, patch, MagicMock from pathlib import Path from signalwire.search.index_builder import IndexBuilder @@ -24,13 +24,13 @@ class TestIndexBuilderInit: """Test IndexBuilder initialization""" - + def test_default_initialization(self) -> None: """Test default initialization""" builder = IndexBuilder() - - assert builder.model_name == 'sentence-transformers/all-mpnet-base-v2' - assert builder.chunking_strategy == 'sentence' + + assert builder.model_name == "sentence-transformers/all-mpnet-base-v2" + assert builder.chunking_strategy == "sentence" assert builder.max_sentences_per_chunk == 5 assert builder.chunk_size == 50 assert builder.chunk_overlap == 10 @@ -40,21 +40,21 @@ def test_default_initialization(self) -> None: assert builder.doc_processor is not None assert builder.semantic_threshold == 0.5 assert builder.topic_threshold == 0.3 - + def test_custom_initialization(self) -> None: """Test initialization with custom parameters""" builder = IndexBuilder( - model_name='custom-model', - chunking_strategy='sliding', + model_name="custom-model", + chunking_strategy="sliding", max_sentences_per_chunk=25, chunk_size=100, chunk_overlap=20, split_newlines=3, - verbose=True + verbose=True, ) - - assert builder.model_name == 'custom-model' - assert builder.chunking_strategy == 'sliding' + + assert builder.model_name == "custom-model" + assert builder.chunking_strategy == "sliding" assert builder.max_sentences_per_chunk == 25 assert builder.chunk_size == 100 assert builder.chunk_overlap == 20 @@ -64,46 +64,48 @@ def test_custom_initialization(self) -> None: class TestIndexBuilderModelLoading: """Test model loading functionality""" - + def setup_method(self) -> None: """Set up test fixtures""" self.builder = IndexBuilder() - - @patch('signalwire.search.index_builder.SentenceTransformer', None) + + @patch("signalwire.search.index_builder.SentenceTransformer", None) def test_load_model_no_library(self) -> None: """Test model loading without sentence-transformers""" with pytest.raises(ImportError, match="sentence-transformers is required"): self.builder._load_model() - - @patch('signalwire.search.index_builder.SentenceTransformer') + + @patch("signalwire.search.index_builder.SentenceTransformer") def test_load_model_success(self, mock_transformer: MagicMock) -> None: """Test successful model loading""" mock_model = Mock() mock_transformer.return_value = mock_model - + self.builder._load_model() - - mock_transformer.assert_called_once_with('sentence-transformers/all-mpnet-base-v2') + + mock_transformer.assert_called_once_with( + "sentence-transformers/all-mpnet-base-v2" + ) assert self.builder.model == mock_model - - @patch('signalwire.search.index_builder.SentenceTransformer') + + @patch("signalwire.search.index_builder.SentenceTransformer") def test_load_model_error(self, mock_transformer: MagicMock) -> None: """Test model loading with error""" mock_transformer.side_effect = Exception("Model loading failed") - + with pytest.raises(Exception, match="Model loading failed"): self.builder._load_model() - - @patch('signalwire.search.index_builder.SentenceTransformer') + + @patch("signalwire.search.index_builder.SentenceTransformer") def test_load_model_lazy_loading(self, mock_transformer: MagicMock) -> None: """Test that model is only loaded once""" mock_model = Mock() mock_transformer.return_value = mock_model - + # First call should load model self.builder._load_model() assert mock_transformer.call_count == 1 - + # Second call should not load again self.builder._load_model() assert mock_transformer.call_count == 1 @@ -111,104 +113,111 @@ def test_load_model_lazy_loading(self, mock_transformer: MagicMock) -> None: class TestIndexBuilderFileDiscovery: """Test file discovery functionality""" - + def setup_method(self) -> None: """Set up test fixtures""" self.builder = IndexBuilder() - + def test_is_file_excluded_no_patterns(self) -> None: """Test file exclusion with no patterns""" file_path = Path("test.txt") - + result = self.builder._is_file_excluded(file_path, None) - + assert result is False - + def test_is_file_excluded_with_patterns(self) -> None: """Test file exclusion with patterns""" file_path = Path("temp/test.txt") exclude_patterns = ["temp/*", "*.log"] - + result = self.builder._is_file_excluded(file_path, exclude_patterns) - + assert result is True - + def test_is_file_excluded_not_matching(self) -> None: """Test file exclusion with non-matching patterns""" file_path = Path("docs/test.txt") exclude_patterns = ["temp/*", "*.log"] - + result = self.builder._is_file_excluded(file_path, exclude_patterns) - + assert result is False - - @patch('pathlib.Path.is_dir') - @patch('pathlib.Path.rglob') - @patch('pathlib.Path.exists') - def test_discover_files_from_directory(self, mock_exists: MagicMock, mock_rglob: MagicMock, mock_is_dir: MagicMock) -> None: + + @patch("pathlib.Path.is_dir") + @patch("pathlib.Path.rglob") + @patch("pathlib.Path.exists") + def test_discover_files_from_directory( + self, mock_exists: MagicMock, mock_rglob: MagicMock, mock_is_dir: MagicMock + ) -> None: """Test file discovery from directory""" mock_is_dir.return_value = True mock_exists.return_value = True mock_files = [Path("test1.txt"), Path("test2.py"), Path("test3.txt")] mock_rglob.return_value = mock_files - + sources = [Path("test_dir")] file_types = ["txt", "py"] - - with patch.object(self.builder, '_discover_files', return_value=mock_files): + + with patch.object(self.builder, "_discover_files", return_value=mock_files): result = self.builder._discover_files_from_sources(sources, file_types) - + assert len(result) == 3 assert all(f in result for f in mock_files) - - @patch('pathlib.Path.is_dir') - @patch('pathlib.Path.is_file') - def test_discover_files_from_individual_files(self, mock_is_file: MagicMock, mock_is_dir: MagicMock) -> None: + + @patch("pathlib.Path.is_dir") + @patch("pathlib.Path.is_file") + def test_discover_files_from_individual_files( + self, mock_is_file: MagicMock, mock_is_dir: MagicMock + ) -> None: """Test file discovery from individual files""" mock_is_dir.return_value = False mock_is_file.return_value = True - + # Create mock Path objects with proper suffix mock_path1 = Mock(spec=Path) mock_path1.suffix = ".txt" mock_path1.__str__ = Mock(return_value="test1.txt") # type: ignore[method-assign] # mock __str__ on spec'd Path - + mock_path2 = Mock(spec=Path) mock_path2.suffix = ".py" mock_path2.__str__ = Mock(return_value="test2.py") # type: ignore[method-assign] # mock __str__ on spec'd Path - + sources = [mock_path1, mock_path2] file_types = ["txt", "py"] - - with patch.object(self.builder, '_is_file_excluded', return_value=False): + + with patch.object(self.builder, "_is_file_excluded", return_value=False): result = self.builder._discover_files_from_sources(sources, file_types) # type: ignore[arg-type] # mock Path objects - + assert len(result) == 2 assert mock_path1 in result assert mock_path2 in result - - @patch('pathlib.Path.is_dir') - @patch('pathlib.Path.is_file') - def test_discover_files_with_exclusions(self, mock_is_file: MagicMock, mock_is_dir: MagicMock) -> None: + + @patch("pathlib.Path.is_dir") + @patch("pathlib.Path.is_file") + def test_discover_files_with_exclusions( + self, mock_is_file: MagicMock, mock_is_dir: MagicMock + ) -> None: """Test file discovery with exclusions""" mock_is_dir.return_value = False mock_is_file.return_value = True - + # Create mock Path objects mock_path1 = Mock(spec=Path) mock_path1.suffix = ".txt" mock_path1.__str__ = Mock(return_value="test1.txt") # type: ignore[method-assign] # mock __str__ on spec'd Path - + mock_path2 = Mock(spec=Path) mock_path2.suffix = ".txt" mock_path2.__str__ = Mock(return_value="temp/test2.txt") # type: ignore[method-assign] # mock __str__ on spec'd Path - + sources = [mock_path1, mock_path2] file_types = ["txt"] exclude_patterns = ["temp/*"] - - result = self.builder._discover_files_from_sources(sources, file_types, exclude_patterns) # type: ignore[arg-type] # mock Path objects - + + discover = self.builder._discover_files_from_sources + result = discover(sources, file_types, exclude_patterns) # type: ignore[arg-type] # mock Path objects + assert len(result) == 1 assert mock_path1 in result assert mock_path2 not in result @@ -216,141 +225,151 @@ def test_discover_files_with_exclusions(self, mock_is_file: MagicMock, mock_is_d class TestIndexBuilderFileProcessing: """Test file processing functionality""" - + def setup_method(self) -> None: """Set up test fixtures""" self.builder = IndexBuilder() - + def test_get_base_directory_for_individual_file(self) -> None: """Test base directory calculation for individual files""" file_path = Path("/home/user/docs/test.txt") sources = [file_path] - + result = self.builder._get_base_directory_for_file(file_path, sources) - + assert result == str(file_path.parent) - + def test_get_base_directory_for_directory_file(self) -> None: """Test base directory calculation for files from directories""" # Create mock Path objects mock_file_path = Mock(spec=Path) mock_source_path = Mock(spec=Path) mock_source_path.is_dir.return_value = True - + # Mock relative_to to succeed (not raise ValueError) mock_file_path.relative_to.return_value = Path("subdir/test.txt") - + sources = [mock_source_path] - + result = self.builder._get_base_directory_for_file(mock_file_path, sources) # type: ignore[arg-type] # mock Path objects - + assert result == str(mock_source_path) - - @patch('pathlib.Path.read_text') + + @patch("pathlib.Path.read_text") def test_process_file_success(self, mock_read_text: MagicMock) -> None: """Test successful file processing""" mock_read_text.return_value = "Test content for processing" - + # Create mock Path object mock_file_path = Mock(spec=Path) mock_file_path.read_text.return_value = "Test content for processing" mock_file_path.relative_to.return_value = Path("test.txt") mock_file_path.suffix = ".txt" - + source_dir = "/home/user" - + mock_chunks = [ {"content": "Test content", "filename": "test.txt"}, - {"content": "for processing", "filename": "test.txt"} + {"content": "for processing", "filename": "test.txt"}, ] - - with patch.object(self.builder.doc_processor, 'create_chunks', return_value=mock_chunks): + + with patch.object( + self.builder.doc_processor, "create_chunks", return_value=mock_chunks + ): result = self.builder._process_file(mock_file_path, source_dir) - + assert len(result) == 2 assert result[0]["content"] == "Test content" assert result[1]["content"] == "for processing" - - @patch('pathlib.Path.read_text') + + @patch("pathlib.Path.read_text") def test_process_file_with_tags(self, mock_read_text: MagicMock) -> None: """Test file processing with global tags""" mock_read_text.return_value = "Test content" - + # Create mock Path object mock_file_path = Mock(spec=Path) mock_file_path.read_text.return_value = "Test content" mock_file_path.relative_to.return_value = Path("test.txt") mock_file_path.suffix = ".txt" - + source_dir = "/home/user" global_tags = ["tag1", "tag2"] - - mock_chunks = [{"content": "Test content", "filename": "test.txt", "tags": ["existing"]}] - - with patch.object(self.builder.doc_processor, 'create_chunks', return_value=mock_chunks): + + mock_chunks = [ + {"content": "Test content", "filename": "test.txt", "tags": ["existing"]} + ] + + with patch.object( + self.builder.doc_processor, "create_chunks", return_value=mock_chunks + ): result = self.builder._process_file(mock_file_path, source_dir, global_tags) - + assert result[0]["tags"] == ["existing", "tag1", "tag2"] - - @patch('pathlib.Path.read_text') + + @patch("pathlib.Path.read_text") def test_process_file_unicode_error(self, mock_read_text: MagicMock) -> None: """Test file processing with unicode error""" mock_read_text.side_effect = UnicodeDecodeError("utf-8", b"", 0, 1, "invalid") file_path = Path("test.bin") source_dir = "/home/user" - + result = self.builder._process_file(file_path, source_dir) - + assert result == [] - - @patch('pathlib.Path.read_text') + + @patch("pathlib.Path.read_text") def test_process_file_general_error(self, mock_read_text: MagicMock) -> None: """Test file processing with general error""" mock_read_text.side_effect = Exception("File error") file_path = Path("test.txt") source_dir = "/home/user" - + result = self.builder._process_file(file_path, source_dir) - + assert result == [] def test_process_file_with_string_tags(self) -> None: """Test file processing with string tags instead of list""" builder = IndexBuilder() - - mock_chunks = [{"content": "Test", "filename": "test.txt", "tags": "single_tag"}] - + + mock_chunks = [ + {"content": "Test", "filename": "test.txt", "tags": "single_tag"} + ] + # Create mock Path object mock_file_path = Mock(spec=Path) mock_file_path.read_text.return_value = "content" mock_file_path.relative_to.return_value = Path("test.txt") mock_file_path.suffix = ".txt" - - with patch.object(builder.doc_processor, 'create_chunks', return_value=mock_chunks): + + with patch.object( + builder.doc_processor, "create_chunks", return_value=mock_chunks + ): result = builder._process_file(mock_file_path, "/home", ["global_tag"]) - + # Should convert string tag to list and add global tags assert result[0]["tags"] == ["single_tag", "global_tag"] class TestIndexBuilderDatabaseCreation: """Test database creation functionality""" - + def setup_method(self) -> None: """Set up test fixtures""" self.builder = IndexBuilder() self.temp_db: str | None = None - + def teardown_method(self) -> None: """Clean up test fixtures""" - if self.temp_db and os.path.exists(self.temp_db): - os.remove(self.temp_db) - + if self.temp_db: + Path(self.temp_db).unlink(missing_ok=True) + def test_create_database_basic(self) -> None: """Test basic database creation""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: self.temp_db = f.name - + chunks = [ { "content": "Test content", @@ -363,73 +382,79 @@ def test_create_database_basic(self) -> None: "start_line": 1, "end_line": 5, "tags": ["tag1"], - "metadata": {"key": "value"} + "metadata": {"key": "value"}, } ] - + languages = ["en"] sources_info = ["/home/user/docs"] file_types = ["txt"] - - self.builder._create_database(self.temp_db, chunks, languages, sources_info, file_types) - + + self.builder._create_database( + self.temp_db, chunks, languages, sources_info, file_types + ) + # Verify database was created - assert os.path.exists(self.temp_db) - + assert Path(self.temp_db).exists() + # Verify schema conn = sqlite3.connect(self.temp_db) cursor = conn.cursor() - + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") tables = [row[0] for row in cursor.fetchall()] - - expected_tables = ['chunks', 'chunks_fts', 'synonyms', 'config'] + + expected_tables = ["chunks", "chunks_fts", "synonyms", "config"] assert all(table in tables for table in expected_tables) - + # Verify data cursor.execute("SELECT COUNT(*) FROM chunks") assert cursor.fetchone()[0] == 1 - + cursor.execute("SELECT content, filename FROM chunks") row = cursor.fetchone() assert row[0] == "Test content" assert row[1] == "test.txt" - + conn.close() - + def test_create_database_with_existing_file(self) -> None: """Test database creation with existing file""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: self.temp_db = f.name f.write(b"existing content") - + chunks = [{"content": "Test", "filename": "test.txt", "embedding": b"data"}] - + self.builder._create_database(self.temp_db, chunks, ["en"], ["/home"], ["txt"]) - + # File should be recreated - assert os.path.exists(self.temp_db) - + assert Path(self.temp_db).exists() + conn = sqlite3.connect(self.temp_db) cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM chunks") assert cursor.fetchone()[0] == 1 conn.close() - - @patch('signalwire.search.index_builder.np') - def test_create_database_with_numpy_embedding_dimensions(self, mock_np: MagicMock) -> None: + + @patch("signalwire.search.index_builder.np") + def test_create_database_with_numpy_embedding_dimensions( + self, mock_np: MagicMock + ) -> None: """Test database creation with numpy embedding dimension detection""" mock_array = Mock() mock_array.__len__ = Mock(return_value=512) mock_np.frombuffer.return_value = mock_array - - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: self.temp_db = f.name - - chunks = [{"content": "Test", "filename": "test.txt", "embedding": b"fake_data"}] - + + chunks = [ + {"content": "Test", "filename": "test.txt", "embedding": b"fake_data"} + ] + self.builder._create_database(self.temp_db, chunks, ["en"], ["/home"], ["txt"]) - + conn = sqlite3.connect(self.temp_db) cursor = conn.cursor() cursor.execute("SELECT value FROM config WHERE key='embedding_dimensions'") @@ -440,239 +465,375 @@ def test_create_database_with_numpy_embedding_dimensions(self, mock_np: MagicMoc class TestIndexBuilderIndexValidation: """Test index validation functionality""" - + def setup_method(self) -> None: """Set up test fixtures""" self.builder = IndexBuilder() self.temp_db: str | None = None - + def teardown_method(self) -> None: """Clean up test fixtures""" - if self.temp_db and os.path.exists(self.temp_db): - os.remove(self.temp_db) - + if self.temp_db: + Path(self.temp_db).unlink(missing_ok=True) + def test_validate_index_nonexistent_file(self) -> None: """Test validation of non-existent index file""" result = self.builder.validate_index("nonexistent.db") - + assert result["valid"] is False assert "does not exist" in result["error"] - + def test_validate_index_valid_file(self) -> None: """Test validation of valid index file""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: self.temp_db = f.name - + # Create a valid database chunks = [ {"content": "Test", "filename": "test1.txt", "embedding": b"data"}, {"content": "Test2", "filename": "test2.txt", "embedding": b"data"}, - {"content": "Test3", "filename": "test1.txt", "embedding": b"data"} + {"content": "Test3", "filename": "test1.txt", "embedding": b"data"}, ] self.builder._create_database(self.temp_db, chunks, ["en"], ["/home"], ["txt"]) - + result = self.builder.validate_index(self.temp_db) - + assert result["valid"] is True assert result["chunk_count"] == 3 assert result["file_count"] == 2 # 2 unique filenames assert "config" in result - + def test_validate_index_missing_tables(self) -> None: """Test validation of index with missing tables""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: self.temp_db = f.name - + # Create incomplete database conn = sqlite3.connect(self.temp_db) cursor = conn.cursor() cursor.execute("CREATE TABLE chunks (id INTEGER)") conn.commit() conn.close() - + result = self.builder.validate_index(self.temp_db) - + assert result["valid"] is False assert "Missing tables" in result["error"] - + def test_validate_index_database_error(self) -> None: """Test validation with database error""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: self.temp_db = f.name f.write(b"invalid sqlite data") - + result = self.builder.validate_index(self.temp_db) - + assert result["valid"] is False assert "error" in result +class TestValidateIndexClosesConnection: + """`validate_index` must close its sqlite connection on EVERY return path. + + A leaked handle is invisible on POSIX (unlink succeeds regardless) but on + Windows it makes the file undeletable -- `PermissionError: [WinError 32] The + process cannot access the file because it is being used by another process`, + which is how this surfaced (nightly Multi-OS run 30238061313, windows-latest). + + These tests assert the platform-independent invariant -- that every connection + opened is also closed -- so the Windows-only bug is provable on POSIX too. + """ + + def _connect_spy(self, monkeypatch: pytest.MonkeyPatch) -> list[sqlite3.Connection]: + """Record every Connection validate_index opens, so we can assert it closed.""" + opened: list[sqlite3.Connection] = [] + real_connect = sqlite3.connect + + def spy(*args: Any, **kwargs: Any) -> sqlite3.Connection: + # `sqlite3.connect` is overloaded, so calling it through *args widens + # the result to Any; annotate to keep the spy's return type honest. + conn: sqlite3.Connection = real_connect(*args, **kwargs) + opened.append(conn) + return conn + + monkeypatch.setattr("signalwire.search.index_builder.sqlite3.connect", spy) + return opened + + @staticmethod + def _is_closed(conn: sqlite3.Connection) -> bool: + """A closed Connection raises ProgrammingError on any further use.""" + try: + conn.execute("SELECT 1") + except sqlite3.ProgrammingError: + return True + return False + + def test_missing_tables_path_closes_connection( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The `Missing tables` early return must not leak the handle.""" + db = tmp_path / "missing_tables.db" + with closing(sqlite3.connect(str(db))) as setup: + setup.execute("CREATE TABLE chunks (id INTEGER)") + setup.commit() + + opened = self._connect_spy(monkeypatch) + result = IndexBuilder().validate_index(str(db)) + + assert result["valid"] is False + assert "Missing tables" in result["error"] + assert len(opened) == 1, ( + "expected validate_index to open exactly one connection" + ) + assert self._is_closed(opened[0]), ( + "connection leaked on the missing-tables path" + ) + + # The operation Windows refuses when a handle is still open. + db.unlink() + + def test_database_error_path_closes_connection( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The exception path must not leak the handle either. + + `sqlite3.connect` is lazy, so it succeeds on a non-database file and the + failure surfaces from the first query -- inside the `try`, after the + connection exists. + """ + db = tmp_path / "not_a_database.db" + db.write_bytes(b"invalid sqlite data") + + opened = self._connect_spy(monkeypatch) + result = IndexBuilder().validate_index(str(db)) + + assert result["valid"] is False + assert "error" in result + assert len(opened) == 1, ( + "expected validate_index to open exactly one connection" + ) + assert self._is_closed(opened[0]), "connection leaked on the error path" + + db.unlink() + + def test_valid_index_path_closes_connection( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The success path must close too (it did before; guard against regression).""" + db = tmp_path / "valid.db" + builder = IndexBuilder() + builder._create_database( + str(db), + [{"content": "Test", "filename": "t.txt", "embedding": b"data"}], + ["en"], + ["/src"], + ["txt"], + ) + + opened = self._connect_spy(monkeypatch) + result = builder.validate_index(str(db)) + + assert result["valid"] is True + assert len(opened) == 1 + assert self._is_closed(opened[0]), "connection leaked on the success path" + + db.unlink() + + class TestIndexBuilderBuildMethods: """Test index building methods""" - + def setup_method(self) -> None: """Set up test fixtures""" self.builder = IndexBuilder(verbose=True) self.temp_db: str | None = None - + def teardown_method(self) -> None: """Clean up test fixtures""" - if self.temp_db and os.path.exists(self.temp_db): - os.remove(self.temp_db) - - @patch('signalwire.search.index_builder.preprocess_document_content') + if self.temp_db: + Path(self.temp_db).unlink(missing_ok=True) + + @patch("signalwire.search.index_builder.preprocess_document_content") def test_build_index_from_sources_success(self, mock_preprocess: MagicMock) -> None: """Test successful index building from sources""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: self.temp_db = f.name - + # Mock preprocessing mock_preprocess.return_value = { "enhanced_text": "enhanced content", - "keywords": ["test", "content"] + "keywords": ["test", "content"], } - + # Mock file discovery mock_files = [Path("test1.txt"), Path("test2.txt")] - + # Mock file processing mock_chunks = [ {"content": "Test content 1", "filename": "test1.txt"}, - {"content": "Test content 2", "filename": "test2.txt"} + {"content": "Test content 2", "filename": "test2.txt"}, ] - + # Mock model mock_model = Mock() mock_embedding = Mock() mock_embedding.tobytes.return_value = b"fake_embedding" mock_model.encode.return_value = mock_embedding - - with patch.object(self.builder, '_discover_files_from_sources', return_value=mock_files), \ - patch.object(self.builder, '_process_file', side_effect=[mock_chunks[:1], mock_chunks[1:]]), \ - patch.object(self.builder, '_load_model'), \ - patch.object(self.builder, '_create_database') as mock_create_db: - + + with ( + patch.object( + self.builder, "_discover_files_from_sources", return_value=mock_files + ), + patch.object( + self.builder, + "_process_file", + side_effect=[mock_chunks[:1], mock_chunks[1:]], + ), + patch.object(self.builder, "_load_model"), + patch.object(self.builder, "_create_database") as mock_create_db, + ): self.builder.model = mock_model - + sources = [Path("/home/user/docs")] file_types = ["txt"] - + self.builder.build_index_from_sources(sources, self.temp_db, file_types) - + # Verify methods were called mock_create_db.assert_called_once() assert mock_model.encode.call_count == 2 - - def test_build_index_from_sources_no_files(self) -> None: + + def test_build_index_from_sources_no_files(self, tmp_path: Path) -> None: """Test index building with no files found""" - # Don't create temp file since method should return early - temp_db = "/tmp/nonexistent.db" - - with patch.object(self.builder, '_discover_files_from_sources', return_value=[]): + # A path inside tmp_path that is deliberately never created: the method + # must return early. (`tmp_path`, not a hardcoded /tmp -- project rule, + # and it guarantees a clean directory.) + temp_db = str(tmp_path / "nonexistent.db") + + with patch.object( + self.builder, "_discover_files_from_sources", return_value=[] + ): sources = [Path("/empty/dir")] file_types = ["txt"] - + # Should return early without creating database self.builder.build_index_from_sources(sources, temp_db, file_types) - + # Database should not be created - assert not os.path.exists(temp_db) - - def test_build_index_from_sources_no_chunks(self) -> None: + assert not Path(temp_db).exists() + + def test_build_index_from_sources_no_chunks(self, tmp_path: Path) -> None: """Test index building with no chunks created""" - # Don't create temp file since method should return early - temp_db = "/tmp/nonexistent2.db" - + # Deliberately-absent path; the method must return early. + temp_db = str(tmp_path / "nonexistent2.db") + mock_files = [Path("test.txt")] - - with patch.object(self.builder, '_discover_files_from_sources', return_value=mock_files), \ - patch.object(self.builder, '_process_file', return_value=[]): - + + with ( + patch.object( + self.builder, "_discover_files_from_sources", return_value=mock_files + ), + patch.object(self.builder, "_process_file", return_value=[]), + ): sources = [Path("/home/user/docs")] file_types = ["txt"] - + # Should return early without creating database self.builder.build_index_from_sources(sources, temp_db, file_types) - + # Database should not be created - assert not os.path.exists(temp_db) - - @patch('signalwire.search.index_builder.np') + assert not Path(temp_db).exists() + + @patch("signalwire.search.index_builder.np") def test_build_index_from_sources_embedding_error(self, mock_np: MagicMock) -> None: """Test index building with embedding generation error""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: self.temp_db = f.name - + # Mock numpy for fallback embedding mock_zeros = Mock() mock_zeros.tobytes.return_value = b"zero_embedding" mock_np.zeros.return_value = mock_zeros - + mock_files = [Path("test.txt")] mock_chunks = [{"content": "Test content", "filename": "test.txt"}] - + # Mock model that raises error mock_model = Mock() mock_model.encode.side_effect = Exception("Embedding error") - - with patch.object(self.builder, '_discover_files_from_sources', return_value=mock_files), \ - patch.object(self.builder, '_process_file', return_value=mock_chunks), \ - patch.object(self.builder, '_load_model'), \ - patch.object(self.builder, '_create_database') as mock_create_db, \ - patch('signalwire.search.index_builder.preprocess_document_content') as mock_preprocess: - + + with ( + patch.object( + self.builder, "_discover_files_from_sources", return_value=mock_files + ), + patch.object(self.builder, "_process_file", return_value=mock_chunks), + patch.object(self.builder, "_load_model"), + patch.object(self.builder, "_create_database") as mock_create_db, + patch( + "signalwire.search.index_builder.preprocess_document_content" + ) as mock_preprocess, + ): mock_preprocess.return_value = {"enhanced_text": "enhanced", "keywords": []} self.builder.model = mock_model - + sources = [Path("/home/user/docs")] file_types = ["txt"] - + # Should handle error gracefully self.builder.build_index_from_sources(sources, self.temp_db, file_types) - + # Database should still be created with fallback embedding mock_create_db.assert_called_once() - + def test_build_index_legacy_method(self) -> None: """Test legacy build_index method""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: self.temp_db = f.name - - with patch.object(self.builder, 'build_index_from_sources') as mock_build: + + with patch.object(self.builder, "build_index_from_sources") as mock_build: source_dir = "/home/user/docs" file_types = ["txt"] - + self.builder.build_index(source_dir, self.temp_db, file_types) - + # Should call new method with converted parameters - mock_build.assert_called_once_with([Path(source_dir)], self.temp_db, file_types, None, None, None) + mock_build.assert_called_once_with( + [Path(source_dir)], self.temp_db, file_types, None, None, None + ) class TestIndexBuilderEdgeCases: """Test edge cases and error handling""" - + def test_create_database_embedding_dimension_error(self) -> None: """Test database creation with embedding dimension detection error""" builder = IndexBuilder() - - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: temp_db = f.name - + try: - chunks = [{"content": "Test", "filename": "test.txt", "embedding": b"invalid_data"}] - - with patch('signalwire.search.index_builder.np') as mock_np: + chunks = [ + { + "content": "Test", + "filename": "test.txt", + "embedding": b"invalid_data", + } + ] + + with patch("signalwire.search.index_builder.np") as mock_np: mock_np.frombuffer.side_effect = Exception("Invalid buffer") - + # Should handle error gracefully and use default dimensions builder._create_database(temp_db, chunks, ["en"], ["/home"], ["txt"]) - + conn = sqlite3.connect(temp_db) cursor = conn.cursor() - cursor.execute("SELECT value FROM config WHERE key='embedding_dimensions'") + cursor.execute( + "SELECT value FROM config WHERE key='embedding_dimensions'" + ) dimensions = cursor.fetchone()[0] assert dimensions == "768" # Default conn.close() finally: - if os.path.exists(temp_db): - os.remove(temp_db) \ No newline at end of file + Path(temp_db).unlink(missing_ok=True) diff --git a/tests/unit/search/test_pgvector_backend.py b/tests/unit/search/test_pgvector_backend.py index bdafeff8..ab600d93 100644 --- a/tests/unit/search/test_pgvector_backend.py +++ b/tests/unit/search/test_pgvector_backend.py @@ -14,7 +14,7 @@ import pytest import json import logging -from unittest.mock import Mock, patch, MagicMock, call, PropertyMock +from unittest.mock import Mock, patch, MagicMock from datetime import datetime from collections.abc import Iterable, Iterator from typing import Any @@ -31,37 +31,58 @@ # objects whose str() contains the actual SQL text and identifiers. # --------------------------------------------------------------------------- + +class _ConnectFailure(Exception): + """Stands in for the driver error psycopg2.connect() raises. + + psycopg2 is not installed in the test environment (the whole module is + mocked), so ``psycopg2.OperationalError`` is not importable here. A + dedicated type lets the tests assert that ``_connect`` re-raises the + driver's own exception unchanged instead of passing on any error at all. + """ + + class _FakeIdentifier: """Mimics psycopg2.sql.Identifier for testing.""" + def __init__(self, name: str) -> None: self._name = name + def __repr__(self) -> str: return f"Identifier({self._name!r})" + def __str__(self) -> str: return str(self._name) class _FakeComposed: """Mimics psycopg2.sql.Composed for testing.""" + def __init__(self, parts: Iterable[Any]) -> None: self._parts = list(parts) + def __str__(self) -> str: return "".join(str(p) for p in self._parts) + def as_string(self, conn: Any) -> str: return str(self) class _FakeSQL: """Mimics psycopg2.sql.SQL for testing.""" + def __init__(self, template: str) -> None: self._template = template + def format(self, **kwargs: Any) -> "_FakeComposed": result = self._template for key, val in kwargs.items(): result = result.replace("{" + key + "}", str(val)) return _FakeComposed([result]) + def join(self, parts: Iterable[Any]) -> "_FakeComposed": return _FakeComposed([str(p) for p in parts]) + def __str__(self) -> str: return str(self._template) @@ -86,23 +107,27 @@ class _FakeSqlModule: import sys # Create a proper module-like object for psycopg2.sql -_fake_sql_module = type('Module', (), { - 'SQL': _FakeSQL, 'Identifier': _FakeIdentifier, - '__name__': 'psycopg2.sql', -})() +_fake_sql_module = type( + "Module", + (), + { + "SQL": _FakeSQL, + "Identifier": _FakeIdentifier, + "__name__": "psycopg2.sql", + }, +)() mock_psycopg2.sql = _fake_sql_module # Force-set modules (don't use setdefault for psycopg2 since it may already be set) -sys.modules['psycopg2'] = mock_psycopg2 -sys.modules['psycopg2.sql'] = _fake_sql_module -sys.modules['psycopg2.extras'] = mock_psycopg2_extras -sys.modules.setdefault('pgvector', MagicMock()) -sys.modules.setdefault('pgvector.psycopg2', mock_pgvector_psycopg2) +sys.modules["psycopg2"] = mock_psycopg2 +sys.modules["psycopg2.sql"] = _fake_sql_module +sys.modules["psycopg2.extras"] = mock_psycopg2_extras +sys.modules.setdefault("pgvector", MagicMock()) +sys.modules.setdefault("pgvector.psycopg2", mock_pgvector_psycopg2) # Force reload the module to pick up the fake SQL classes -import importlib -if 'signalwire.search.pgvector_backend' in sys.modules: - del sys.modules['signalwire.search.pgvector_backend'] +if "signalwire.search.pgvector_backend" in sys.modules: + del sys.modules["signalwire.search.pgvector_backend"] # Now import the module under test. Because we injected the mocks above, # PGVECTOR_AVAILABLE will be True and psycopg2_sql will be our fake module. @@ -117,6 +142,7 @@ class _FakeSqlModule: # Helper: build a mock psycopg2 connection and cursor # --------------------------------------------------------------------------- + def _make_mock_conn() -> tuple[MagicMock, MagicMock]: """Return a mock connection with a context-managed cursor.""" mock_conn = MagicMock() @@ -138,9 +164,9 @@ def _make_mock_conn() -> tuple[MagicMock, MagicMock]: class TestPgVectorBackendInit: """Test PgVectorBackend initialization""" - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") def test_init_success(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: """Test successful initialization connects and registers vector""" mock_conn = MagicMock() @@ -153,26 +179,30 @@ def test_init_success(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: assert backend.connection_string == "postgresql://localhost/testdb" assert backend.conn is mock_conn - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', False) + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", False) def test_init_pgvector_not_available(self) -> None: """Test initialization raises ImportError when pgvector is not installed""" with pytest.raises(ImportError, match="pgvector dependencies not available"): PgVectorBackend("postgresql://localhost/testdb") - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_init_connection_failure(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_init_connection_failure( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """Test initialization when connection fails""" mock_pg.connect.side_effect = Exception("Connection refused") with pytest.raises(Exception, match="Connection refused"): PgVectorBackend("postgresql://localhost/testdb") - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_init_vector_type_not_found(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_init_vector_type_not_found( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """Test initialization when vector extension is missing in database""" mock_pg.connect.side_effect = Exception("vector type not found in database") @@ -186,7 +216,11 @@ class TestPgVectorBackendConnect: @pytest.fixture(autouse=True) def _enable_propagation(self) -> Iterator[None]: """Ensure logging is configured and propagation is on so caplog works.""" - from signalwire.core.logging_config import reset_logging_configuration, configure_logging + from signalwire.core.logging_config import ( + reset_logging_configuration, + configure_logging, + ) + reset_logging_configuration() configure_logging() sw = logging.getLogger("signalwire") @@ -194,10 +228,12 @@ def _enable_propagation(self) -> Iterator[None]: yield sw.propagate = False - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_connect_logs_success(self, mock_reg: MagicMock, mock_pg: MagicMock, caplog: pytest.LogCaptureFixture) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_connect_logs_success( + self, mock_reg: MagicMock, mock_pg: MagicMock, caplog: pytest.LogCaptureFixture + ) -> None: """Test that successful connection logs info message""" mock_conn = MagicMock() mock_pg.connect.return_value = mock_conn @@ -206,30 +242,43 @@ def test_connect_logs_success(self, mock_reg: MagicMock, mock_pg: MagicMock, cap backend = PgVectorBackend("postgresql://localhost/testdb") assert "Connected to PostgreSQL database" in caplog.text + # The success log must correspond to a real connection being retained, + # not just an info line emitted on the way past. + assert backend.conn is mock_conn - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_connect_vector_type_error_logs_specific_message(self, mock_reg: MagicMock, mock_pg: MagicMock, caplog: pytest.LogCaptureFixture) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_connect_vector_type_error_logs_specific_message( + self, mock_reg: MagicMock, mock_pg: MagicMock, caplog: pytest.LogCaptureFixture + ) -> None: """Test vector type not found produces specific error log""" - mock_pg.connect.side_effect = Exception("vector type not found in the catalog") + mock_pg.connect.side_effect = _ConnectFailure( + "vector type not found in the catalog" + ) - with caplog.at_level(logging.ERROR, logger="signalwire.search.pgvector_backend"): - with pytest.raises(Exception): - PgVectorBackend("postgresql://localhost/testdb") + with ( + caplog.at_level(logging.ERROR, logger="signalwire.search.pgvector_backend"), + pytest.raises(_ConnectFailure, match="vector type not found"), + ): + PgVectorBackend("postgresql://localhost/testdb") assert "pgvector extension not installed" in caplog.text - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_connect_generic_error_logs_message(self, mock_reg: MagicMock, mock_pg: MagicMock, caplog: pytest.LogCaptureFixture) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_connect_generic_error_logs_message( + self, mock_reg: MagicMock, mock_pg: MagicMock, caplog: pytest.LogCaptureFixture + ) -> None: """Test generic connection failure produces error log""" - mock_pg.connect.side_effect = Exception("host not reachable") + mock_pg.connect.side_effect = _ConnectFailure("host not reachable") - with caplog.at_level(logging.ERROR, logger="signalwire.search.pgvector_backend"): - with pytest.raises(Exception): - PgVectorBackend("postgresql://localhost/testdb") + with ( + caplog.at_level(logging.ERROR, logger="signalwire.search.pgvector_backend"), + pytest.raises(_ConnectFailure, match="host not reachable"), + ): + PgVectorBackend("postgresql://localhost/testdb") assert "Failed to connect to database" in caplog.text @@ -237,10 +286,12 @@ def test_connect_generic_error_logs_message(self, mock_reg: MagicMock, mock_pg: class TestPgVectorBackendEnsureConnection: """Test PgVectorBackend _ensure_connection""" - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_ensure_connection_when_open(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_ensure_connection_when_open( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """Test _ensure_connection does nothing when connection is alive""" mock_conn = MagicMock() mock_conn.closed = False @@ -253,10 +304,12 @@ def test_ensure_connection_when_open(self, mock_reg: MagicMock, mock_pg: MagicMo # Should not reconnect assert mock_pg.connect.call_count == 1 - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_ensure_connection_reconnects_when_closed(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_ensure_connection_reconnects_when_closed( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """Test _ensure_connection reconnects when connection is closed""" mock_conn = MagicMock() mock_conn.closed = False @@ -270,10 +323,12 @@ def test_ensure_connection_reconnects_when_closed(self, mock_reg: MagicMock, moc backend._ensure_connection() assert mock_pg.connect.call_count == 2 - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_ensure_connection_reconnects_when_none(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_ensure_connection_reconnects_when_none( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """Test _ensure_connection reconnects when conn is None""" mock_conn = MagicMock() mock_conn.closed = False @@ -292,9 +347,11 @@ class TestPgVectorBackendCreateSchema: def _make_backend(self) -> tuple[PgVectorBackend, MagicMock, MagicMock]: """Create a PgVectorBackend with a mocked connection.""" - with patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True), \ - patch('signalwire.search.pgvector_backend.psycopg2') as mock_pg, \ - patch('signalwire.search.pgvector_backend.register_vector'): + with ( + patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True), + patch("signalwire.search.pgvector_backend.psycopg2") as mock_pg, + patch("signalwire.search.pgvector_backend.register_vector"), + ): mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn backend = PgVectorBackend("postgresql://localhost/testdb") @@ -312,33 +369,51 @@ def test_create_schema_creates_table_and_indexes(self) -> None: ] # Extensions - assert any("CREATE EXTENSION IF NOT EXISTS vector" in sql for sql in executed_sqls) - assert any("CREATE EXTENSION IF NOT EXISTS pg_trgm" in sql for sql in executed_sqls) + assert any( + "CREATE EXTENSION IF NOT EXISTS vector" in sql for sql in executed_sqls + ) + assert any( + "CREATE EXTENSION IF NOT EXISTS pg_trgm" in sql for sql in executed_sqls + ) # Main table - assert any("CREATE TABLE IF NOT EXISTS chunks_my_collection" in sql for sql in executed_sqls) + assert any( + "CREATE TABLE IF NOT EXISTS chunks_my_collection" in sql + for sql in executed_sqls + ) # embedding dimension is passed as a parameter, check it's in the params - create_table_calls = [c for c in mock_cursor.execute.call_args_list - if "CREATE TABLE IF NOT EXISTS" in str(c[0][0]) and "chunks_" in str(c[0][0])] + create_table_calls = [ + c + for c in mock_cursor.execute.call_args_list + if "CREATE TABLE IF NOT EXISTS" in str(c[0][0]) + and "chunks_" in str(c[0][0]) + ] assert len(create_table_calls) > 0 # The dimension is passed as a parameter tuple - assert any(512 in (c[0][1] if len(c[0]) > 1 else ()) for c in create_table_calls) + assert any( + 512 in (c[0][1] if len(c[0]) > 1 else ()) for c in create_table_calls + ) # Indexes (embedding, content, tags, metadata, metadata_text) assert any("idx_chunks_my_collection_embedding" in sql for sql in executed_sqls) assert any("idx_chunks_my_collection_content" in sql for sql in executed_sqls) assert any("idx_chunks_my_collection_tags" in sql for sql in executed_sqls) assert any("idx_chunks_my_collection_metadata" in sql for sql in executed_sqls) - assert any("idx_chunks_my_collection_metadata_text" in sql for sql in executed_sqls) + assert any( + "idx_chunks_my_collection_metadata_text" in sql for sql in executed_sqls + ) # Config table - assert any("CREATE TABLE IF NOT EXISTS collection_config" in sql for sql in executed_sqls) + assert any( + "CREATE TABLE IF NOT EXISTS collection_config" in sql + for sql in executed_sqls + ) mock_conn.commit.assert_called_once() def test_create_schema_sanitizes_collection_name(self) -> None: """Test that special characters in collection name are replaced""" - backend, mock_conn, mock_cursor = self._make_backend() + backend, _mock_conn, mock_cursor = self._make_backend() backend.create_schema("my-collection.v2!", embedding_dim=768) @@ -355,21 +430,27 @@ def test_create_schema_sanitizes_collection_name(self) -> None: def test_create_schema_default_embedding_dim(self) -> None: """Test create_schema uses default embedding_dim of 768""" - backend, mock_conn, mock_cursor = self._make_backend() + backend, _mock_conn, mock_cursor = self._make_backend() backend.create_schema("test") # embedding dimension is passed as a parameter, check it's in the params - create_table_calls = [c for c in mock_cursor.execute.call_args_list - if "CREATE TABLE IF NOT EXISTS" in str(c[0][0]) and "chunks_" in str(c[0][0])] + create_table_calls = [ + c + for c in mock_cursor.execute.call_args_list + if "CREATE TABLE IF NOT EXISTS" in str(c[0][0]) + and "chunks_" in str(c[0][0]) + ] assert len(create_table_calls) > 0 - assert any(768 in (c[0][1] if len(c[0]) > 1 else ()) for c in create_table_calls) + assert any( + 768 in (c[0][1] if len(c[0]) > 1 else ()) for c in create_table_calls + ) def test_create_schema_calls_ensure_connection(self) -> None: """Test that create_schema checks connection""" - backend, mock_conn, mock_cursor = self._make_backend() + backend, _mock_conn, _mock_cursor = self._make_backend() - with patch.object(backend, '_ensure_connection') as mock_ensure: + with patch.object(backend, "_ensure_connection") as mock_ensure: backend.create_schema("test") mock_ensure.assert_called_once() @@ -378,9 +459,11 @@ class TestPgVectorBackendExtractMetadata: """Test PgVectorBackend _extract_metadata_from_json_content""" def _make_backend(self) -> PgVectorBackend: - with patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True), \ - patch('signalwire.search.pgvector_backend.psycopg2') as mock_pg, \ - patch('signalwire.search.pgvector_backend.register_vector'): + with ( + patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True), + patch("signalwire.search.pgvector_backend.psycopg2") as mock_pg, + patch("signalwire.search.pgvector_backend.register_vector"), + ): mock_conn = MagicMock() mock_conn.closed = False mock_pg.connect.return_value = mock_conn @@ -389,7 +472,9 @@ def _make_backend(self) -> PgVectorBackend: def test_extract_no_metadata(self) -> None: """Test extraction from content with no metadata key""" backend = self._make_backend() - result = backend._extract_metadata_from_json_content("plain text with no metadata") + result = backend._extract_metadata_from_json_content( + "plain text with no metadata" + ) assert result == {} def test_extract_valid_json_metadata(self) -> None: @@ -404,9 +489,7 @@ def test_extract_multiple_metadata_blocks(self) -> None: """Test extraction merges multiple metadata blocks""" backend = self._make_backend() content = ( - '{"metadata": {"key1": "val1"}} ' - 'some text ' - '{"metadata": {"key2": "val2"}}' + '{"metadata": {"key1": "val1"}} some text {"metadata": {"key2": "val2"}}' ) result = backend._extract_metadata_from_json_content(content) assert result.get("key1") == "val1" @@ -431,11 +514,13 @@ def test_extract_metadata_keyword_but_no_json(self) -> None: class TestPgVectorBackendStoreChunks: """Test PgVectorBackend store_chunks""" - @patch('signalwire.search.pgvector_backend.execute_values') - @patch('signalwire.search.pgvector_backend.register_vector') - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - def test_store_chunks_basic(self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.execute_values") + @patch("signalwire.search.pgvector_backend.register_vector") + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + def test_store_chunks_basic( + self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock + ) -> None: """Test storing a single chunk""" mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn @@ -468,13 +553,15 @@ def test_store_chunks_basic(self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ mock_cursor.execute.assert_called() mock_conn.commit.assert_called() - @patch('signalwire.search.pgvector_backend.execute_values') - @patch('signalwire.search.pgvector_backend.register_vector') - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - def test_store_chunks_numpy_embedding(self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.execute_values") + @patch("signalwire.search.pgvector_backend.register_vector") + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + def test_store_chunks_numpy_embedding( + self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock + ) -> None: """Test storing chunks with numpy array embeddings""" - mock_conn, mock_cursor = _make_mock_conn() + mock_conn, _mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn backend = PgVectorBackend("postgresql://localhost/testdb") @@ -495,13 +582,15 @@ def test_store_chunks_numpy_embedding(self, mock_pg: MagicMock, mock_reg: MagicM mock_embedding.tolist.assert_called_once() mock_ev.assert_called_once() - @patch('signalwire.search.pgvector_backend.execute_values') - @patch('signalwire.search.pgvector_backend.register_vector') - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - def test_store_chunks_no_embedding(self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.execute_values") + @patch("signalwire.search.pgvector_backend.register_vector") + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + def test_store_chunks_no_embedding( + self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock + ) -> None: """Test storing chunks without embeddings""" - mock_conn, mock_cursor = _make_mock_conn() + mock_conn, _mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn backend = PgVectorBackend("postgresql://localhost/testdb") @@ -519,13 +608,15 @@ def test_store_chunks_no_embedding(self, mock_pg: MagicMock, mock_reg: MagicMock data_arg = mock_ev.call_args[0][2] assert data_arg[0][2] is None # embedding position - @patch('signalwire.search.pgvector_backend.execute_values') - @patch('signalwire.search.pgvector_backend.register_vector') - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - def test_store_chunks_metadata_from_chunk_keys(self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.execute_values") + @patch("signalwire.search.pgvector_backend.register_vector") + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + def test_store_chunks_metadata_from_chunk_keys( + self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock + ) -> None: """Test that extra chunk keys become metadata""" - mock_conn, mock_cursor = _make_mock_conn() + mock_conn, _mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn backend = PgVectorBackend("postgresql://localhost/testdb") @@ -547,13 +638,15 @@ def test_store_chunks_metadata_from_chunk_keys(self, mock_pg: MagicMock, mock_re assert metadata_json["custom_field"] == "custom_value" assert metadata_json["another_field"] == 42 - @patch('signalwire.search.pgvector_backend.execute_values') - @patch('signalwire.search.pgvector_backend.register_vector') - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - def test_store_chunks_metadata_text_generation(self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.execute_values") + @patch("signalwire.search.pgvector_backend.register_vector") + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + def test_store_chunks_metadata_text_generation( + self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock + ) -> None: """Test that searchable metadata text is generated correctly""" - mock_conn, mock_cursor = _make_mock_conn() + mock_conn, _mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn backend = PgVectorBackend("postgresql://localhost/testdb") @@ -578,20 +671,19 @@ def test_store_chunks_metadata_text_generation(self, mock_pg: MagicMock, mock_re assert "overview" in metadata_text # section, lowered assert "bob" in metadata_text or "author" in metadata_text - @patch('signalwire.search.pgvector_backend.execute_values') - @patch('signalwire.search.pgvector_backend.register_vector') - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - def test_store_chunks_multiple(self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.execute_values") + @patch("signalwire.search.pgvector_backend.register_vector") + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + def test_store_chunks_multiple( + self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock + ) -> None: """Test storing multiple chunks at once""" - mock_conn, mock_cursor = _make_mock_conn() + mock_conn, _mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn backend = PgVectorBackend("postgresql://localhost/testdb") - chunks = [ - {"content": f"Chunk {i}", "embedding": None} - for i in range(5) - ] + chunks = [{"content": f"Chunk {i}", "embedding": None} for i in range(5)] config: dict[str, Any] = {} backend.store_chunks(chunks, "col", config) @@ -600,13 +692,15 @@ def test_store_chunks_multiple(self, mock_pg: MagicMock, mock_reg: MagicMock, mo data_arg = mock_ev.call_args[0][2] assert len(data_arg) == 5 - @patch('signalwire.search.pgvector_backend.execute_values') - @patch('signalwire.search.pgvector_backend.register_vector') - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - def test_store_chunks_json_metadata_in_content(self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.execute_values") + @patch("signalwire.search.pgvector_backend.register_vector") + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + def test_store_chunks_json_metadata_in_content( + self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock + ) -> None: """Test metadata extraction from JSON content during storage""" - mock_conn, mock_cursor = _make_mock_conn() + mock_conn, _mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn backend = PgVectorBackend("postgresql://localhost/testdb") @@ -626,25 +720,29 @@ def test_store_chunks_json_metadata_in_content(self, mock_pg: MagicMock, mock_re # JSON metadata should be merged (but chunk metadata takes precedence) assert "source" in metadata_json - @patch('signalwire.search.pgvector_backend.execute_values') - @patch('signalwire.search.pgvector_backend.register_vector') - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - def test_store_chunks_calls_ensure_connection(self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.execute_values") + @patch("signalwire.search.pgvector_backend.register_vector") + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + def test_store_chunks_calls_ensure_connection( + self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock + ) -> None: """Test that store_chunks calls _ensure_connection""" - mock_conn, mock_cursor = _make_mock_conn() + mock_conn, _mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn backend = PgVectorBackend("postgresql://localhost/testdb") - with patch.object(backend, '_ensure_connection') as mock_ensure: + with patch.object(backend, "_ensure_connection") as mock_ensure: backend.store_chunks([{"content": "test", "embedding": None}], "col", {}) mock_ensure.assert_called_once() - @patch('signalwire.search.pgvector_backend.execute_values') - @patch('signalwire.search.pgvector_backend.register_vector') - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - def test_store_chunks_config_upsert(self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.execute_values") + @patch("signalwire.search.pgvector_backend.register_vector") + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + def test_store_chunks_config_upsert( + self, mock_pg: MagicMock, mock_reg: MagicMock, mock_ev: MagicMock + ) -> None: """Test that config is upserted with ON CONFLICT""" mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn @@ -679,9 +777,11 @@ class TestPgVectorBackendGetStats: """Test PgVectorBackend get_stats""" def _make_backend(self) -> tuple[PgVectorBackend, MagicMock, MagicMock]: - with patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True), \ - patch('signalwire.search.pgvector_backend.psycopg2') as mock_pg, \ - patch('signalwire.search.pgvector_backend.register_vector'): + with ( + patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True), + patch("signalwire.search.pgvector_backend.psycopg2") as mock_pg, + patch("signalwire.search.pgvector_backend.register_vector"), + ): mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn backend = PgVectorBackend("postgresql://localhost/testdb") @@ -689,15 +789,20 @@ def _make_backend(self) -> tuple[PgVectorBackend, MagicMock, MagicMock]: def test_get_stats_with_config(self) -> None: """Test get_stats returns correct statistics with config""" - backend, mock_conn, mock_cursor = self._make_backend() + backend, _mock_conn, mock_cursor = self._make_backend() created_at = datetime(2025, 1, 15, 12, 0, 0) mock_cursor.fetchone.side_effect = [ - (42,), # total chunks - (7,), # unique files - ( # config row - "test_col", "model-v1", 768, "sentence", - ["en"], created_at, {"version": 1} + (42,), # total chunks + (7,), # unique files + ( # config row + "test_col", + "model-v1", + 768, + "sentence", + ["en"], + created_at, + {"version": 1}, ), ] @@ -714,12 +819,12 @@ def test_get_stats_with_config(self) -> None: def test_get_stats_without_config(self) -> None: """Test get_stats when no config exists""" - backend, mock_conn, mock_cursor = self._make_backend() + backend, _mock_conn, mock_cursor = self._make_backend() mock_cursor.fetchone.side_effect = [ (10,), # total chunks - (3,), # unique files - None, # no config row + (3,), # unique files + None, # no config row ] stats = backend.get_stats("missing_col") @@ -730,7 +835,7 @@ def test_get_stats_without_config(self) -> None: def test_get_stats_with_none_created_at(self) -> None: """Test get_stats when created_at is None""" - backend, mock_conn, mock_cursor = self._make_backend() + backend, _mock_conn, mock_cursor = self._make_backend() mock_cursor.fetchone.side_effect = [ (0,), @@ -746,9 +851,11 @@ class TestPgVectorBackendListCollections: """Test PgVectorBackend list_collections""" def _make_backend(self) -> tuple[PgVectorBackend, MagicMock, MagicMock]: - with patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True), \ - patch('signalwire.search.pgvector_backend.psycopg2') as mock_pg, \ - patch('signalwire.search.pgvector_backend.register_vector'): + with ( + patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True), + patch("signalwire.search.pgvector_backend.psycopg2") as mock_pg, + patch("signalwire.search.pgvector_backend.register_vector"), + ): mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn backend = PgVectorBackend("postgresql://localhost/testdb") @@ -756,7 +863,7 @@ def _make_backend(self) -> tuple[PgVectorBackend, MagicMock, MagicMock]: def test_list_collections_returns_names(self) -> None: """Test list_collections returns collection names""" - backend, mock_conn, mock_cursor = self._make_backend() + backend, _mock_conn, mock_cursor = self._make_backend() mock_cursor.fetchall.return_value = [("alpha",), ("beta",), ("gamma",)] result = backend.list_collections() @@ -765,7 +872,7 @@ def test_list_collections_returns_names(self) -> None: def test_list_collections_empty(self) -> None: """Test list_collections with no collections""" - backend, mock_conn, mock_cursor = self._make_backend() + backend, _mock_conn, mock_cursor = self._make_backend() mock_cursor.fetchall.return_value = [] result = backend.list_collections() @@ -774,10 +881,10 @@ def test_list_collections_empty(self) -> None: def test_list_collections_calls_ensure_connection(self) -> None: """Test that list_collections checks connection""" - backend, mock_conn, mock_cursor = self._make_backend() + backend, _mock_conn, mock_cursor = self._make_backend() mock_cursor.fetchall.return_value = [] - with patch.object(backend, '_ensure_connection') as mock_ensure: + with patch.object(backend, "_ensure_connection") as mock_ensure: backend.list_collections() mock_ensure.assert_called_once() @@ -786,9 +893,11 @@ class TestPgVectorBackendDeleteCollection: """Test PgVectorBackend delete_collection""" def _make_backend(self) -> tuple[PgVectorBackend, MagicMock, MagicMock]: - with patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True), \ - patch('signalwire.search.pgvector_backend.psycopg2') as mock_pg, \ - patch('signalwire.search.pgvector_backend.register_vector'): + with ( + patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True), + patch("signalwire.search.pgvector_backend.psycopg2") as mock_pg, + patch("signalwire.search.pgvector_backend.register_vector"), + ): mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn backend = PgVectorBackend("postgresql://localhost/testdb") @@ -800,28 +909,34 @@ def test_delete_collection_drops_table_and_config(self) -> None: backend.delete_collection("my_collection") - executed_sqls = [str(c[0][0]).strip() for c in mock_cursor.execute.call_args_list] + executed_sqls = [ + str(c[0][0]).strip() for c in mock_cursor.execute.call_args_list + ] - assert any("DROP TABLE IF EXISTS chunks_my_collection" in sql for sql in executed_sqls) + assert any( + "DROP TABLE IF EXISTS chunks_my_collection" in sql for sql in executed_sqls + ) assert any("DELETE FROM collection_config" in sql for sql in executed_sqls) mock_conn.commit.assert_called() def test_delete_collection_sanitizes_name(self) -> None: """Test that delete_collection sanitizes collection name""" - backend, mock_conn, mock_cursor = self._make_backend() + backend, _mock_conn, mock_cursor = self._make_backend() backend.delete_collection("bad-name.here!") - executed_sqls = [str(c[0][0]).strip() for c in mock_cursor.execute.call_args_list] + executed_sqls = [ + str(c[0][0]).strip() for c in mock_cursor.execute.call_args_list + ] # The DROP should use the sanitized name assert any("chunks_bad_name_here_" in sql for sql in executed_sqls) def test_delete_collection_calls_ensure_connection(self) -> None: """Test that delete_collection checks connection""" - backend, mock_conn, mock_cursor = self._make_backend() + backend, _mock_conn, _mock_cursor = self._make_backend() - with patch.object(backend, '_ensure_connection') as mock_ensure: + with patch.object(backend, "_ensure_connection") as mock_ensure: backend.delete_collection("test") mock_ensure.assert_called_once() @@ -829,10 +944,12 @@ def test_delete_collection_calls_ensure_connection(self) -> None: class TestPgVectorBackendClose: """Test PgVectorBackend close""" - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_close_closes_connection(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_close_closes_connection( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """Test close closes the database connection""" mock_conn = MagicMock() mock_conn.closed = False @@ -843,10 +960,12 @@ def test_close_closes_connection(self, mock_reg: MagicMock, mock_pg: MagicMock) mock_conn.close.assert_called_once() - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_close_already_closed(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_close_already_closed( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """Test close does nothing when connection is already closed""" mock_conn = MagicMock() mock_conn.closed = False @@ -858,10 +977,12 @@ def test_close_already_closed(self, mock_reg: MagicMock, mock_pg: MagicMock) -> mock_conn.close.assert_not_called() - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_close_when_conn_is_none(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_close_when_conn_is_none( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """close() must early-return when conn is None — no connection.close() is invoked because there is no connection.""" mock_conn = MagicMock() @@ -887,15 +1008,21 @@ def test_close_when_conn_is_none(self, mock_reg: MagicMock, mock_pg: MagicMock) class TestPgVectorSearchBackendInit: """Test PgVectorSearchBackend initialization""" - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") def test_init_success(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: """Test successful initialization""" mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn mock_cursor.fetchone.return_value = ( - "test_col", "model-v1", 768, "sentence", ["en"], datetime.now(), {} + "test_col", + "model-v1", + 768, + "sentence", + ["en"], + datetime.now(), + {}, ) sb = PgVectorSearchBackend("postgresql://localhost/testdb", "test_col") @@ -906,25 +1033,27 @@ def test_init_success(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: assert sb.conn is mock_conn assert sb.config["model_name"] == "model-v1" - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', False) + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", False) def test_init_pgvector_not_available(self) -> None: """Test initialization raises ImportError when pgvector not installed""" with pytest.raises(ImportError, match="pgvector dependencies not available"): PgVectorSearchBackend("postgresql://localhost/testdb", "col") - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_init_connection_failure(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_init_connection_failure( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """Test initialization when connection fails""" mock_pg.connect.side_effect = Exception("Connection refused") with pytest.raises(Exception, match="Connection refused"): PgVectorSearchBackend("postgresql://localhost/testdb", "col") - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") def test_init_no_config(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: """Test initialization when collection has no config""" mock_conn, mock_cursor = _make_mock_conn() @@ -939,11 +1068,15 @@ def test_init_no_config(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: class TestPgVectorSearchBackendLoadConfig: """Test PgVectorSearchBackend _load_config""" - def _make_search_backend(self, config_row: "tuple[Any, ...] | None" = None) -> tuple[PgVectorSearchBackend, MagicMock, MagicMock]: + def _make_search_backend( + self, config_row: "tuple[Any, ...] | None" = None + ) -> tuple[PgVectorSearchBackend, MagicMock, MagicMock]: """Create a PgVectorSearchBackend with mocked connection.""" - with patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True), \ - patch('signalwire.search.pgvector_backend.psycopg2') as mock_pg, \ - patch('signalwire.search.pgvector_backend.register_vector'): + with ( + patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True), + patch("signalwire.search.pgvector_backend.psycopg2") as mock_pg, + patch("signalwire.search.pgvector_backend.register_vector"), + ): mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn mock_cursor.fetchone.return_value = config_row @@ -952,7 +1085,15 @@ def _make_search_backend(self, config_row: "tuple[Any, ...] | None" = None) -> t def test_load_config_with_data(self) -> None: """Test _load_config returns config when row exists""" - config_row = ("col", "model-v1", 512, "sliding", ["en", "de"], datetime.now(), {"k": "v"}) + config_row = ( + "col", + "model-v1", + 512, + "sliding", + ["en", "de"], + datetime.now(), + {"k": "v"}, + ) sb, _, _ = self._make_search_backend(config_row) assert sb.config["model_name"] == "model-v1" @@ -970,10 +1111,14 @@ def test_load_config_no_data(self) -> None: class TestPgVectorSearchBackendVectorSearch: """Test PgVectorSearchBackend _vector_search""" - def _make_search_backend(self) -> tuple[PgVectorSearchBackend, MagicMock, MagicMock]: - with patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True), \ - patch('signalwire.search.pgvector_backend.psycopg2') as mock_pg, \ - patch('signalwire.search.pgvector_backend.register_vector'): + def _make_search_backend( + self, + ) -> tuple[PgVectorSearchBackend, MagicMock, MagicMock]: + with ( + patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True), + patch("signalwire.search.pgvector_backend.psycopg2") as mock_pg, + patch("signalwire.search.pgvector_backend.register_vector"), + ): mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn # For _load_config @@ -1063,10 +1208,14 @@ def test_vector_search_tags_not_list(self) -> None: class TestPgVectorSearchBackendKeywordSearch: """Test PgVectorSearchBackend _keyword_search""" - def _make_search_backend(self) -> tuple[PgVectorSearchBackend, MagicMock, MagicMock]: - with patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True), \ - patch('signalwire.search.pgvector_backend.psycopg2') as mock_pg, \ - patch('signalwire.search.pgvector_backend.register_vector'): + def _make_search_backend( + self, + ) -> tuple[PgVectorSearchBackend, MagicMock, MagicMock]: + with ( + patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True), + patch("signalwire.search.pgvector_backend.psycopg2") as mock_pg, + patch("signalwire.search.pgvector_backend.register_vector"), + ): mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn mock_cursor.fetchone.return_value = None @@ -1078,7 +1227,15 @@ def test_keyword_search_returns_results(self) -> None: """Test _keyword_search returns properly formatted results""" sb, _, mock_cursor = self._make_search_backend() mock_cursor.fetchall.return_value = [ - (1, "Python tutorial", "python.md", "intro", ["python"], {"level": "beginner"}, 5.0), + ( + 1, + "Python tutorial", + "python.md", + "intro", + ["python"], + {"level": "beginner"}, + 5.0, + ), ] results = sb._keyword_search("Python tutorial", count=5) @@ -1125,10 +1282,14 @@ def test_keyword_search_empty(self) -> None: class TestPgVectorSearchBackendMetadataSearch: """Test PgVectorSearchBackend _metadata_search""" - def _make_search_backend(self) -> tuple[PgVectorSearchBackend, MagicMock, MagicMock]: - with patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True), \ - patch('signalwire.search.pgvector_backend.psycopg2') as mock_pg, \ - patch('signalwire.search.pgvector_backend.register_vector'): + def _make_search_backend( + self, + ) -> tuple[PgVectorSearchBackend, MagicMock, MagicMock]: + with ( + patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True), + patch("signalwire.search.pgvector_backend.psycopg2") as mock_pg, + patch("signalwire.search.pgvector_backend.register_vector"), + ): mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn mock_cursor.fetchone.return_value = None @@ -1140,7 +1301,15 @@ def test_metadata_search_basic(self) -> None: """Test _metadata_search returns properly formatted results""" sb, _, mock_cursor = self._make_search_backend() mock_cursor.fetchall.return_value = [ - (1, "Content A", "file.txt", "intro", ["tag1"], {"author": "alice"}, "author alice tag1 intro"), + ( + 1, + "Content A", + "file.txt", + "intro", + ["tag1"], + {"author": "alice"}, + "author alice tag1 intro", + ), ] results = sb._metadata_search(["alice"], count=5) @@ -1167,8 +1336,15 @@ def test_metadata_search_score_capped_at_one(self) -> None: """Test that metadata search score is capped at 1.0""" sb, _, mock_cursor = self._make_search_backend() mock_cursor.fetchall.return_value = [ - (1, "C", "f", "s", [], {"a": "b", "c": "d", "e": "f"}, - "a b c d e f g h i j k"), + ( + 1, + "C", + "f", + "s", + [], + {"a": "b", "c": "d", "e": "f"}, + "a b c d e f g h i j k", + ), ] results = sb._metadata_search(["a", "b", "c", "d", "e"], count=5) @@ -1255,24 +1431,37 @@ class TestPgVectorSearchBackendMergeResults: """Test PgVectorSearchBackend _merge_results""" def _make_search_backend(self) -> PgVectorSearchBackend: - with patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True), \ - patch('signalwire.search.pgvector_backend.psycopg2') as mock_pg, \ - patch('signalwire.search.pgvector_backend.register_vector'): + with ( + patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True), + patch("signalwire.search.pgvector_backend.psycopg2") as mock_pg, + patch("signalwire.search.pgvector_backend.register_vector"), + ): mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn mock_cursor.fetchone.return_value = None - sb = PgVectorSearchBackend("postgresql://localhost/testdb", "col") - return sb + return PgVectorSearchBackend("postgresql://localhost/testdb", "col") def test_merge_results_default_weights(self) -> None: """Test _merge_results uses default keyword_weight of 0.3""" sb = self._make_search_backend() vector_results = [ - {"id": 1, "content": "V1", "score": 1.0, "search_type": "vector", "metadata": {}}, + { + "id": 1, + "content": "V1", + "score": 1.0, + "search_type": "vector", + "metadata": {}, + }, ] keyword_results = [ - {"id": 1, "content": "K1", "score": 1.0, "search_type": "keyword", "metadata": {}}, + { + "id": 1, + "content": "K1", + "score": 1.0, + "search_type": "keyword", + "metadata": {}, + }, ] merged = sb._merge_results(vector_results, keyword_results) @@ -1286,10 +1475,22 @@ def test_merge_results_custom_keyword_weight(self) -> None: sb = self._make_search_backend() vector_results = [ - {"id": 1, "content": "V1", "score": 1.0, "search_type": "vector", "metadata": {}}, + { + "id": 1, + "content": "V1", + "score": 1.0, + "search_type": "vector", + "metadata": {}, + }, ] keyword_results = [ - {"id": 1, "content": "K1", "score": 1.0, "search_type": "keyword", "metadata": {}}, + { + "id": 1, + "content": "K1", + "score": 1.0, + "search_type": "keyword", + "metadata": {}, + }, ] merged = sb._merge_results(vector_results, keyword_results, keyword_weight=0.5) @@ -1302,10 +1503,22 @@ def test_merge_results_unique_ids(self) -> None: sb = self._make_search_backend() vector_results = [ - {"id": 1, "content": "Only vector", "score": 0.9, "search_type": "vector", "metadata": {}}, + { + "id": 1, + "content": "Only vector", + "score": 0.9, + "search_type": "vector", + "metadata": {}, + }, ] keyword_results = [ - {"id": 2, "content": "Only keyword", "score": 0.8, "search_type": "keyword", "metadata": {}}, + { + "id": 2, + "content": "Only keyword", + "score": 0.8, + "search_type": "keyword", + "metadata": {}, + }, ] merged = sb._merge_results(vector_results, keyword_results) @@ -1317,8 +1530,20 @@ def test_merge_results_sorted_by_score(self) -> None: sb = self._make_search_backend() vector_results = [ - {"id": 1, "content": "V1", "score": 0.5, "search_type": "vector", "metadata": {}}, - {"id": 2, "content": "V2", "score": 0.9, "search_type": "vector", "metadata": {}}, + { + "id": 1, + "content": "V1", + "score": 0.5, + "search_type": "vector", + "metadata": {}, + }, + { + "id": 2, + "content": "V2", + "score": 0.9, + "search_type": "vector", + "metadata": {}, + }, ] keyword_results: list[dict[str, Any]] = [] @@ -1338,14 +1563,15 @@ class TestPgVectorSearchBackendMergeAllResults: """Test PgVectorSearchBackend _merge_all_results""" def _make_search_backend(self) -> PgVectorSearchBackend: - with patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True), \ - patch('signalwire.search.pgvector_backend.psycopg2') as mock_pg, \ - patch('signalwire.search.pgvector_backend.register_vector'): + with ( + patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True), + patch("signalwire.search.pgvector_backend.psycopg2") as mock_pg, + patch("signalwire.search.pgvector_backend.register_vector"), + ): mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn mock_cursor.fetchone.return_value = None - sb = PgVectorSearchBackend("postgresql://localhost/testdb", "col") - return sb + return PgVectorSearchBackend("postgresql://localhost/testdb", "col") def test_merge_all_results_three_sources(self) -> None: """Max-signal-wins scoring with 0.1 per-additional-source agreement boost. @@ -1357,9 +1583,33 @@ def test_merge_all_results_three_sources(self) -> None: """ sb = self._make_search_backend() - vector = [{"id": 1, "content": "V", "score": 0.9, "search_type": "vector", "metadata": {}}] - keyword = [{"id": 1, "content": "K", "score": 0.8, "search_type": "keyword", "metadata": {}}] - metadata = [{"id": 1, "content": "M", "score": 0.7, "search_type": "metadata", "metadata": {}}] + vector = [ + { + "id": 1, + "content": "V", + "score": 0.9, + "search_type": "vector", + "metadata": {}, + } + ] + keyword = [ + { + "id": 1, + "content": "K", + "score": 0.8, + "search_type": "keyword", + "metadata": {}, + } + ] + metadata = [ + { + "id": 1, + "content": "M", + "score": 0.7, + "search_type": "metadata", + "metadata": {}, + } + ] merged = sb._merge_all_results(vector, keyword, metadata) @@ -1372,8 +1622,24 @@ def test_merge_all_results_includes_sources(self) -> None: """Test _merge_all_results includes source breakdown""" sb = self._make_search_backend() - vector = [{"id": 1, "content": "V", "score": 0.9, "search_type": "vector", "metadata": {}}] - keyword = [{"id": 1, "content": "K", "score": 0.8, "search_type": "keyword", "metadata": {}}] + vector = [ + { + "id": 1, + "content": "V", + "score": 0.9, + "search_type": "vector", + "metadata": {}, + } + ] + keyword = [ + { + "id": 1, + "content": "K", + "score": 0.8, + "search_type": "keyword", + "metadata": {}, + } + ] metadata: list[dict[str, Any]] = [] merged = sb._merge_all_results(vector, keyword, metadata) @@ -1386,9 +1652,33 @@ def test_merge_all_results_unique_from_different_sources(self) -> None: """Test _merge_all_results handles results unique to each source""" sb = self._make_search_backend() - vector = [{"id": 1, "content": "V", "score": 0.9, "search_type": "vector", "metadata": {}}] - keyword = [{"id": 2, "content": "K", "score": 0.8, "search_type": "keyword", "metadata": {}}] - metadata = [{"id": 3, "content": "M", "score": 0.7, "search_type": "metadata", "metadata": {}}] + vector = [ + { + "id": 1, + "content": "V", + "score": 0.9, + "search_type": "vector", + "metadata": {}, + } + ] + keyword = [ + { + "id": 2, + "content": "K", + "score": 0.8, + "search_type": "keyword", + "metadata": {}, + } + ] + metadata = [ + { + "id": 3, + "content": "M", + "score": 0.7, + "search_type": "metadata", + "metadata": {}, + } + ] merged = sb._merge_all_results(vector, keyword, metadata) @@ -1401,8 +1691,20 @@ def test_merge_all_results_sorted_by_score(self) -> None: sb = self._make_search_backend() vector = [ - {"id": 1, "content": "Low", "score": 0.2, "search_type": "vector", "metadata": {}}, - {"id": 2, "content": "High", "score": 0.95, "search_type": "vector", "metadata": {}}, + { + "id": 1, + "content": "Low", + "score": 0.2, + "search_type": "vector", + "metadata": {}, + }, + { + "id": 2, + "content": "High", + "score": 0.95, + "search_type": "vector", + "metadata": {}, + }, ] merged = sb._merge_all_results(vector, [], []) @@ -1425,7 +1727,15 @@ def test_merge_all_results_custom_keyword_weight_is_noop(self) -> None: """ sb = self._make_search_backend() - keyword = [{"id": 1, "content": "K", "score": 1.0, "search_type": "keyword", "metadata": {}}] + keyword = [ + { + "id": 1, + "content": "K", + "score": 1.0, + "search_type": "keyword", + "metadata": {}, + } + ] merged_default = sb._merge_all_results([], keyword, []) merged_custom = sb._merge_all_results([], keyword, [], keyword_weight=0.8) @@ -1438,14 +1748,15 @@ class TestPgVectorSearchBackendSearch: """Test PgVectorSearchBackend search (main entry point)""" def _make_search_backend(self) -> PgVectorSearchBackend: - with patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True), \ - patch('signalwire.search.pgvector_backend.psycopg2') as mock_pg, \ - patch('signalwire.search.pgvector_backend.register_vector'): + with ( + patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True), + patch("signalwire.search.pgvector_backend.psycopg2") as mock_pg, + patch("signalwire.search.pgvector_backend.register_vector"), + ): mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn mock_cursor.fetchone.return_value = None - sb = PgVectorSearchBackend("postgresql://localhost/testdb", "col") - return sb + return PgVectorSearchBackend("postgresql://localhost/testdb", "col") def test_search_calls_all_sub_searches(self) -> None: """Test search invokes vector, keyword, and metadata searches""" @@ -1468,7 +1779,13 @@ def test_search_returns_limited_results(self) -> None: sb = self._make_search_backend() many_results = [ - {"id": i, "content": f"Result {i}", "score": 1.0 - i * 0.1, "final_score": 1.0 - i * 0.1, "metadata": {}} + { + "id": i, + "content": f"Result {i}", + "score": 1.0 - i * 0.1, + "final_score": 1.0 - i * 0.1, + "metadata": {}, + } for i in range(10) ] @@ -1493,8 +1810,20 @@ def test_search_applies_similarity_threshold_post_merge(self) -> None: sb = self._make_search_backend() merged_output = [ - {"id": 1, "content": "High", "score": 0.9, "final_score": 0.9, "metadata": {}}, - {"id": 2, "content": "Low", "score": 0.3, "final_score": 0.3, "metadata": {}}, + { + "id": 1, + "content": "High", + "score": 0.9, + "final_score": 0.9, + "metadata": {}, + }, + { + "id": 2, + "content": "Low", + "score": 0.3, + "final_score": 0.3, + "metadata": {}, + }, ] sb._vector_search = Mock(return_value=[]) # type: ignore[method-assign] # mock @@ -1513,8 +1842,20 @@ def test_search_no_threshold_keeps_all_vector_results(self) -> None: sb = self._make_search_backend() vector_results = [ - {"id": 1, "content": "High", "score": 0.9, "search_type": "vector", "metadata": {}}, - {"id": 2, "content": "Low", "score": 0.1, "search_type": "vector", "metadata": {}}, + { + "id": 1, + "content": "High", + "score": 0.9, + "search_type": "vector", + "metadata": {}, + }, + { + "id": 2, + "content": "Low", + "score": 0.1, + "search_type": "vector", + "metadata": {}, + }, ] sb._vector_search = Mock(return_value=vector_results) # type: ignore[method-assign] # mock @@ -1541,7 +1882,9 @@ def test_search_with_tags(self) -> None: # All sub-searches should receive tags _, kwargs = sb._vector_search.call_args - assert kwargs.get("tags") == ["python"] or sb._vector_search.call_args[0][2] == ["python"] + assert kwargs.get("tags") == ["python"] or sb._vector_search.call_args[0][ + 2 + ] == ["python"] def test_search_with_keyword_weight(self) -> None: """Test search passes keyword_weight to merge""" @@ -1579,7 +1922,13 @@ def test_search_keeps_existing_score_field(self) -> None: sb = self._make_search_backend() merged = [ - {"id": 1, "content": "Test", "score": 0.9, "final_score": 0.8, "metadata": {}}, + { + "id": 1, + "content": "Test", + "score": 0.9, + "final_score": 0.8, + "metadata": {}, + }, ] sb._vector_search = Mock(return_value=[]) # type: ignore[method-assign] # mock @@ -1625,10 +1974,12 @@ def test_search_vector_count_multiplied(self) -> None: class TestPgVectorSearchBackendGetStats: """Test PgVectorSearchBackend get_stats""" - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_get_stats_creates_pgvector_backend(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_get_stats_creates_pgvector_backend( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """Test get_stats creates a PgVectorBackend internally""" mock_conn, mock_cursor = _make_mock_conn() mock_pg.connect.return_value = mock_conn @@ -1636,7 +1987,7 @@ def test_get_stats_creates_pgvector_backend(self, mock_reg: MagicMock, mock_pg: sb = PgVectorSearchBackend("postgresql://localhost/testdb", "col") - with patch('signalwire.search.pgvector_backend.PgVectorBackend') as MockBackend: + with patch("signalwire.search.pgvector_backend.PgVectorBackend") as MockBackend: mock_inner = MagicMock() mock_inner.get_stats.return_value = {"total_chunks": 100} MockBackend.return_value = mock_inner @@ -1652,10 +2003,12 @@ def test_get_stats_creates_pgvector_backend(self, mock_reg: MagicMock, mock_pg: class TestPgVectorSearchBackendClose: """Test PgVectorSearchBackend close""" - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_close_closes_connection(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_close_closes_connection( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """Test close closes the database connection""" mock_conn = MagicMock() mock_conn.closed = False @@ -1671,10 +2024,12 @@ def test_close_closes_connection(self, mock_reg: MagicMock, mock_pg: MagicMock) mock_conn.close.assert_called_once() - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_close_already_closed(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_close_already_closed( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """Test close does nothing when already closed""" mock_conn = MagicMock() mock_conn.closed = False @@ -1692,10 +2047,12 @@ def test_close_already_closed(self, mock_reg: MagicMock, mock_pg: MagicMock) -> mock_conn.close.assert_not_called() - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_close_when_conn_is_none(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_close_when_conn_is_none( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """close() must early-return when conn is None and not invoke connection.close() on anything.""" mock_conn = MagicMock() @@ -1719,10 +2076,12 @@ def test_close_when_conn_is_none(self, mock_reg: MagicMock, mock_pg: MagicMock) class TestPgVectorSearchBackendEnsureConnection: """Test PgVectorSearchBackend _ensure_connection""" - @patch('signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE', True) - @patch('signalwire.search.pgvector_backend.psycopg2') - @patch('signalwire.search.pgvector_backend.register_vector') - def test_ensure_connection_reconnects_when_closed(self, mock_reg: MagicMock, mock_pg: MagicMock) -> None: + @patch("signalwire.search.pgvector_backend.PGVECTOR_AVAILABLE", True) + @patch("signalwire.search.pgvector_backend.psycopg2") + @patch("signalwire.search.pgvector_backend.register_vector") + def test_ensure_connection_reconnects_when_closed( + self, mock_reg: MagicMock, mock_pg: MagicMock + ) -> None: """Test _ensure_connection reconnects when connection is closed""" mock_conn = MagicMock() mock_conn.closed = False diff --git a/tests/unit/search/test_query_processor.py b/tests/unit/search/test_query_processor.py index d4f771f6..168f8687 100644 --- a/tests/unit/search/test_query_processor.py +++ b/tests/unit/search/test_query_processor.py @@ -13,9 +13,7 @@ import sys -import pytest from unittest.mock import Mock, patch, MagicMock -import logging from signalwire.search.query_processor import ( detect_language, @@ -26,59 +24,59 @@ get_synonyms, remove_duplicate_words, preprocess_query, - preprocess_document_content + preprocess_document_content, ) class TestLanguageDetection: """Test language detection functionality""" - + def test_detect_english(self) -> None: """Test detection of English text""" english_text = "The quick brown fox jumps over the lazy dog" - assert detect_language(english_text) == 'en' - + assert detect_language(english_text) == "en" + def test_detect_spanish(self) -> None: """Test detection of Spanish text""" spanish_text = "El perro come la comida en el parque" - assert detect_language(spanish_text) == 'es' - + assert detect_language(spanish_text) == "es" + def test_detect_mixed_language_english_dominant(self) -> None: """Test detection when English words dominate""" mixed_text = "The dog and el gato are friends" - assert detect_language(mixed_text) == 'en' - + assert detect_language(mixed_text) == "en" + def test_detect_mixed_language_spanish_dominant(self) -> None: """Test detection when Spanish words dominate""" mixed_text = "El perro y the cat son amigos" - assert detect_language(mixed_text) == 'es' - + assert detect_language(mixed_text) == "es" + def test_detect_empty_text(self) -> None: """Test detection with empty text""" - assert detect_language("") == 'en' # Default to English - + assert detect_language("") == "en" # Default to English + def test_detect_unknown_language(self) -> None: """Test detection with unknown language defaults to English""" unknown_text = "xyz abc def ghi" - assert detect_language(unknown_text) == 'en' + assert detect_language(unknown_text) == "en" class TestSpacyModelLoading: """Test spaCy model loading functionality""" - + def test_load_spacy_model_success(self) -> None: """Test successful spaCy model loading""" - with patch('builtins.__import__') as mock_import: + with patch("builtins.__import__") as mock_import: mock_spacy = Mock() mock_model = Mock() mock_spacy.load.return_value = mock_model mock_import.return_value = mock_spacy - - result = load_spacy_model('en') - - mock_spacy.load.assert_called_once_with('en_core_web_sm') + + result = load_spacy_model("en") + + mock_spacy.load.assert_called_once_with("en_core_web_sm") assert result == mock_model - + def test_load_spacy_model_not_found(self) -> None: """Test spaCy model loading when model not found""" # Inject a fake spacy module whose load() raises OSError, so the real @@ -87,62 +85,70 @@ def test_load_spacy_model_not_found(self) -> None: mock_spacy = Mock() mock_spacy.load.side_effect = OSError("Model not found") - with patch.dict(sys.modules, {'spacy': mock_spacy}): - with patch('signalwire.search.query_processor.logger') as mock_logger: - # Reset the global warning flag to ensure warning is shown - with patch('signalwire.search.query_processor._spacy_warning_shown', False): - result = load_spacy_model('en') + with ( + patch.dict(sys.modules, {"spacy": mock_spacy}), + patch("signalwire.search.query_processor.logger") as mock_logger, + # Reset the global warning flag to ensure warning is shown + patch("signalwire.search.query_processor._spacy_warning_shown", False), + ): + result = load_spacy_model("en") - assert result is None - mock_logger.warning.assert_called_once() + assert result is None + mock_logger.warning.assert_called_once() def test_load_spacy_model_import_error(self) -> None: """Test spaCy model loading when spaCy not available""" # Setting the module to None in sys.modules makes `import spacy` raise # ImportError — a version-robust alternative to patching __import__. - with patch.dict(sys.modules, {'spacy': None}): - with patch('signalwire.search.query_processor.logger') as mock_logger: - # Reset the global warning flag to ensure warning is shown - with patch('signalwire.search.query_processor._spacy_warning_shown', False): - result = load_spacy_model('en') - - assert result is None - mock_logger.warning.assert_called_once() - + with ( + patch.dict(sys.modules, {"spacy": None}), + patch("signalwire.search.query_processor.logger") as mock_logger, + # Reset the global warning flag to ensure warning is shown + patch("signalwire.search.query_processor._spacy_warning_shown", False), + ): + result = load_spacy_model("en") + + assert result is None + mock_logger.warning.assert_called_once() + def test_load_spacy_model_different_languages(self) -> None: """Test loading models for different languages""" - with patch('builtins.__import__') as mock_import: + with patch("builtins.__import__") as mock_import: mock_spacy = Mock() mock_model = Mock() mock_spacy.load.return_value = mock_model mock_import.return_value = mock_spacy - + # Test various languages - languages = ['en', 'es', 'fr', 'de', 'it', 'pt'] + languages = ["en", "es", "fr", "de", "it", "pt"] expected_models = [ - 'en_core_web_sm', 'es_core_news_sm', 'fr_core_news_sm', - 'de_core_news_sm', 'it_core_news_sm', 'pt_core_news_sm' + "en_core_web_sm", + "es_core_news_sm", + "fr_core_news_sm", + "de_core_news_sm", + "it_core_news_sm", + "pt_core_news_sm", ] - - for lang, expected_model in zip(languages, expected_models): + + for lang, expected_model in zip(languages, expected_models, strict=True): load_spacy_model(lang) mock_spacy.load.assert_called_with(expected_model) - + def test_load_spacy_model_unknown_language(self) -> None: """Test loading model for unknown language defaults to English""" - with patch('builtins.__import__') as mock_import: + with patch("builtins.__import__") as mock_import: mock_spacy = Mock() mock_model = Mock() mock_spacy.load.return_value = mock_model mock_import.return_value = mock_spacy - - load_spacy_model('unknown') - mock_spacy.load.assert_called_with('en_core_web_sm') + + load_spacy_model("unknown") + mock_spacy.load.assert_called_with("en_core_web_sm") class TestQueryVectorization: """Test query vectorization functionality""" - + def test_vectorize_query_success(self) -> None: """Test successful query vectorization""" from signalwire.search import query_processor as _qp @@ -154,16 +160,20 @@ def test_vectorize_query_success(self) -> None: # Clear model cache so _get_cached_model loads a fresh model _qp._model_cache.clear() - with patch('sentence_transformers.SentenceTransformer', return_value=mock_model) as mock_st: + with patch( + "sentence_transformers.SentenceTransformer", return_value=mock_model + ) as mock_st: result = vectorize_query("test query") - mock_st.assert_called_once_with('sentence-transformers/all-mpnet-base-v2') - mock_model.encode.assert_called_once_with("test query", show_progress_bar=False) + mock_st.assert_called_once_with("sentence-transformers/all-mpnet-base-v2") + mock_model.encode.assert_called_once_with( + "test query", show_progress_bar=False + ) assert result == mock_embedding # Clean up cached mock model _qp._model_cache.clear() - + def test_vectorize_query_import_error(self) -> None: """Test query vectorization when sentence-transformers not available""" from signalwire.search import query_processor as _qp @@ -174,149 +184,153 @@ def test_vectorize_query_import_error(self) -> None: # ImportError — version-robust, unlike patching builtins.__import__ # (which on 3.10 leaks into unrelated imports like numpy). _qp._model_cache.clear() - with patch.dict(sys.modules, {'sentence_transformers': None}): - with patch('signalwire.search.query_processor.logger') as mock_logger: - result = vectorize_query("test query") + with ( + patch.dict(sys.modules, {"sentence_transformers": None}), + patch("signalwire.search.query_processor.logger") as mock_logger, + ): + result = vectorize_query("test query") - assert result is None - mock_logger.error.assert_called_once() + assert result is None + mock_logger.error.assert_called_once() _qp._model_cache.clear() class TestNLTKResources: """Test NLTK resource management""" - - @patch('signalwire.search.query_processor.nltk') + + @patch("signalwire.search.query_processor.nltk") def test_ensure_nltk_resources_already_present(self, mock_nltk: MagicMock) -> None: """Test when NLTK resources are already present""" mock_nltk.data.find.return_value = True - + ensure_nltk_resources() - + # Should not call download if resources are found mock_nltk.download.assert_not_called() - - @patch('signalwire.search.query_processor.nltk') + + @patch("signalwire.search.query_processor.nltk") def test_ensure_nltk_resources_download_needed(self, mock_nltk: MagicMock) -> None: """Test when NLTK resources need to be downloaded""" mock_nltk.data.find.side_effect = LookupError("Resource not found") - + ensure_nltk_resources() - + # Should call download for each missing resource - assert mock_nltk.download.call_count == 5 # punkt, punkt_tab, wordnet, averaged_perceptron_tagger, stopwords - - @patch('signalwire.search.query_processor.nltk') + assert ( + mock_nltk.download.call_count == 5 + ) # punkt, punkt_tab, wordnet, averaged_perceptron_tagger, stopwords + + @patch("signalwire.search.query_processor.nltk") def test_ensure_nltk_resources_download_error(self, mock_nltk: MagicMock) -> None: """Test when NLTK resource download fails""" mock_nltk.data.find.side_effect = LookupError("Resource not found") mock_nltk.download.side_effect = Exception("Download failed") - - with patch('signalwire.search.query_processor.logger') as mock_logger: + + with patch("signalwire.search.query_processor.logger") as mock_logger: ensure_nltk_resources() - + # Should log warnings for failed downloads assert mock_logger.warning.call_count == 5 class TestWordNetUtilities: """Test WordNet utility functions""" - + def test_get_wordnet_pos_known_tags(self) -> None: """Test mapping of known POS tags""" from nltk.corpus import wordnet as wn - - assert get_wordnet_pos('NOUN') == wn.NOUN - assert get_wordnet_pos('VERB') == wn.VERB - assert get_wordnet_pos('ADJ') == wn.ADJ - assert get_wordnet_pos('ADV') == wn.ADV - assert get_wordnet_pos('PROPN') == wn.NOUN - + + assert get_wordnet_pos("NOUN") == wn.NOUN + assert get_wordnet_pos("VERB") == wn.VERB + assert get_wordnet_pos("ADJ") == wn.ADJ + assert get_wordnet_pos("ADV") == wn.ADV + assert get_wordnet_pos("PROPN") == wn.NOUN + def test_get_wordnet_pos_unknown_tag(self) -> None: """Test mapping of unknown POS tag defaults to NOUN""" from nltk.corpus import wordnet as wn - - assert get_wordnet_pos('UNKNOWN') == wn.NOUN - - @patch('signalwire.search.query_processor.wn') + + assert get_wordnet_pos("UNKNOWN") == wn.NOUN + + @patch("signalwire.search.query_processor.wn") def test_get_synonyms_success(self, mock_wn: MagicMock) -> None: """Test successful synonym retrieval""" # Mock WordNet synsets and lemmas mock_lemma1 = Mock() - mock_lemma1.name.return_value = 'happy' + mock_lemma1.name.return_value = "happy" mock_lemma2 = Mock() - mock_lemma2.name.return_value = 'joyful' - + mock_lemma2.name.return_value = "joyful" + mock_synset = Mock() mock_synset.lemmas.return_value = [mock_lemma1, mock_lemma2] - + mock_wn.synsets.return_value = [mock_synset] - mock_wn.NOUN = 'n' - - result = get_synonyms('glad', 'NOUN', max_synonyms=5) - - assert 'happy' in result - assert 'joyful' in result - - @patch('signalwire.search.query_processor.wn') + mock_wn.NOUN = "n" + + result = get_synonyms("glad", "NOUN", max_synonyms=5) + + assert "happy" in result + assert "joyful" in result + + @patch("signalwire.search.query_processor.wn") def test_get_synonyms_no_synsets(self, mock_wn: MagicMock) -> None: """Test synonym retrieval when no synsets found""" mock_wn.synsets.return_value = [] - mock_wn.NOUN = 'n' - - result = get_synonyms('nonexistentword', 'NOUN') - + mock_wn.NOUN = "n" + + result = get_synonyms("nonexistentword", "NOUN") + assert result == [] - - @patch('signalwire.search.query_processor.wn') + + @patch("signalwire.search.query_processor.wn") def test_get_synonyms_error(self, mock_wn: MagicMock) -> None: """Test synonym retrieval with error""" mock_wn.synsets.side_effect = Exception("WordNet error") - - with patch('signalwire.search.query_processor.logger') as mock_logger: - result = get_synonyms('word', 'NOUN') - + + with patch("signalwire.search.query_processor.logger") as mock_logger: + result = get_synonyms("word", "NOUN") + assert result == [] mock_logger.warning.assert_called_once() class TestTextProcessing: """Test text processing utilities""" - + def test_remove_duplicate_words_basic(self) -> None: """Test basic duplicate word removal""" text = "the quick brown fox jumps over the lazy dog" result = remove_duplicate_words(text) - + # Should remove the second "the" words = result.split() - assert words.count('the') == 1 - assert 'quick' in words - assert 'brown' in words - + assert words.count("the") == 1 + assert "quick" in words + assert "brown" in words + def test_remove_duplicate_words_with_punctuation(self) -> None: """Test duplicate removal with punctuation""" text = "Hello, world! Hello again." result = remove_duplicate_words(text) - + # Should preserve punctuation but remove duplicate "Hello" - assert result.count('Hello') == 1 - assert ',' in result or '!' in result - + assert result.count("Hello") == 1 + assert "," in result or "!" in result + def test_remove_duplicate_words_case_insensitive(self) -> None: """Test duplicate removal is case insensitive""" text = "The cat and THE dog" result = remove_duplicate_words(text) - + # Should remove duplicate "THE" (case insensitive) words = result.lower().split() - assert words.count('the') == 1 - + assert words.count("the") == 1 + def test_remove_duplicate_words_empty_string(self) -> None: """Test duplicate removal with empty string""" result = remove_duplicate_words("") assert result == "" - + def test_remove_duplicate_words_no_duplicates(self) -> None: """Test duplicate removal when no duplicates exist""" text = "unique words only here" @@ -326,182 +340,194 @@ def test_remove_duplicate_words_no_duplicates(self) -> None: class TestQueryPreprocessing: """Test query preprocessing functionality""" - - @patch('signalwire.search.query_processor.detect_language') - @patch('signalwire.search.query_processor.load_spacy_model') - def test_preprocess_query_basic(self, mock_load_spacy: MagicMock, mock_detect_lang: MagicMock) -> None: + + @patch("signalwire.search.query_processor.detect_language") + @patch("signalwire.search.query_processor.load_spacy_model") + def test_preprocess_query_basic( + self, mock_load_spacy: MagicMock, mock_detect_lang: MagicMock + ) -> None: """Test basic query preprocessing""" - mock_detect_lang.return_value = 'en' + mock_detect_lang.return_value = "en" mock_load_spacy.return_value = None # Use NLTK fallback - + result = preprocess_query("test query") - - assert 'enhanced_text' in result - assert 'language' in result - assert result['language'] == 'en' - - @patch('signalwire.search.query_processor.detect_language') - @patch('signalwire.search.query_processor.vectorize_query') - def test_preprocess_query_with_vector(self, mock_vectorize: MagicMock, mock_detect_lang: MagicMock) -> None: + + assert "enhanced_text" in result + assert "language" in result + assert result["language"] == "en" + + @patch("signalwire.search.query_processor.detect_language") + @patch("signalwire.search.query_processor.vectorize_query") + def test_preprocess_query_with_vector( + self, mock_vectorize: MagicMock, mock_detect_lang: MagicMock + ) -> None: """Test query preprocessing with vectorization""" - mock_detect_lang.return_value = 'en' + mock_detect_lang.return_value = "en" # Mock vectorize_query to return a numpy-like object with tolist method mock_vector = Mock() mock_vector.tolist.return_value = [0.1, 0.2, 0.3] mock_vectorize.return_value = mock_vector - + result = preprocess_query("test query", vector=True) - - assert 'vector' in result - assert result['vector'] == [0.1, 0.2, 0.3] + + assert "vector" in result + assert result["vector"] == [0.1, 0.2, 0.3] mock_vectorize.assert_called_once() - - @patch('signalwire.search.query_processor.vectorize_query') + + @patch("signalwire.search.query_processor.vectorize_query") def test_preprocess_query_vectorize_only(self, mock_vectorize: MagicMock) -> None: """Test query preprocessing with vectorize_query_param=True""" # Mock vectorize_query to return a numpy-like object with tolist method mock_vector = Mock() mock_vector.tolist.return_value = [0.1, 0.2, 0.3] mock_vectorize.return_value = mock_vector - + result = preprocess_query("test query", vectorize_query_param=True) - - assert 'vector' in result - assert result['vector'] == [0.1, 0.2, 0.3] + + assert "vector" in result + assert result["vector"] == [0.1, 0.2, 0.3] mock_vectorize.assert_called_once_with("test query") - + def test_preprocess_query_auto_language_detection(self) -> None: """Test query preprocessing with automatic language detection""" - with patch('signalwire.search.query_processor.detect_language') as mock_detect: - mock_detect.return_value = 'es' - - result = preprocess_query("hola mundo", language='auto') - + with patch("signalwire.search.query_processor.detect_language") as mock_detect: + mock_detect.return_value = "es" + + result = preprocess_query("hola mundo", language="auto") + mock_detect.assert_called_once_with("hola mundo") - assert result['language'] == 'es' - - @patch('signalwire.search.query_processor.load_spacy_model') + assert result["language"] == "es" + + @patch("signalwire.search.query_processor.load_spacy_model") def test_preprocess_query_with_spacy(self, mock_load_spacy: MagicMock) -> None: """Test query preprocessing with spaCy backend""" # Mock spaCy model and processing mock_doc = Mock() mock_token1 = Mock() - mock_token1.text = 'test' - mock_token1.pos_ = 'NOUN' - mock_token1.lemma_ = 'test' - + mock_token1.text = "test" + mock_token1.pos_ = "NOUN" + mock_token1.lemma_ = "test" + mock_token2 = Mock() - mock_token2.text = 'query' - mock_token2.pos_ = 'NOUN' - mock_token2.lemma_ = 'query' - + mock_token2.text = "query" + mock_token2.pos_ = "NOUN" + mock_token2.lemma_ = "query" + mock_doc.__iter__ = Mock(return_value=iter([mock_token1, mock_token2])) - + mock_nlp = Mock() mock_nlp.return_value = mock_doc mock_load_spacy.return_value = mock_nlp - - result = preprocess_query("test query", nlp_backend='spacy') - - assert 'enhanced_text' in result + + result = preprocess_query("test query", nlp_backend="spacy") + + assert "enhanced_text" in result mock_load_spacy.assert_called_once() - - @patch('signalwire.search.query_processor.get_synonyms') - @patch('signalwire.search.query_processor.load_spacy_model') - def test_preprocess_query_with_synonym_expansion(self, mock_load_spacy: MagicMock, mock_get_synonyms: MagicMock) -> None: + + @patch("signalwire.search.query_processor.get_synonyms") + @patch("signalwire.search.query_processor.load_spacy_model") + def test_preprocess_query_with_synonym_expansion( + self, mock_load_spacy: MagicMock, mock_get_synonyms: MagicMock + ) -> None: """Test query preprocessing with synonym expansion""" mock_load_spacy.return_value = None # Use NLTK - mock_get_synonyms.return_value = ['happy', 'joyful'] - - result = preprocess_query("glad", pos_to_expand=['NN'], max_synonyms=2) - - assert 'enhanced_text' in result + mock_get_synonyms.return_value = ["happy", "joyful"] + + result = preprocess_query("glad", pos_to_expand=["NN"], max_synonyms=2) + + assert "enhanced_text" in result # Should include original word and synonyms - enhanced_text = result['enhanced_text'] - assert 'glad' in enhanced_text + enhanced_text = result["enhanced_text"] + assert "glad" in enhanced_text class TestDocumentPreprocessing: """Test document content preprocessing""" - - @patch('signalwire.search.query_processor.load_spacy_model') - def test_preprocess_document_content_basic(self, mock_load_spacy: MagicMock) -> None: + + @patch("signalwire.search.query_processor.load_spacy_model") + def test_preprocess_document_content_basic( + self, mock_load_spacy: MagicMock + ) -> None: """Test basic document content preprocessing""" mock_load_spacy.return_value = None # Use NLTK fallback - + content = "This is a test document with some content." result = preprocess_document_content(content) - - assert 'enhanced_text' in result - assert 'language' in result - assert result['language'] == 'en' - assert 'keywords' in result - - @patch('signalwire.search.query_processor.load_spacy_model') - def test_preprocess_document_content_with_spacy(self, mock_load_spacy: MagicMock) -> None: + + assert "enhanced_text" in result + assert "language" in result + assert result["language"] == "en" + assert "keywords" in result + + @patch("signalwire.search.query_processor.load_spacy_model") + def test_preprocess_document_content_with_spacy( + self, mock_load_spacy: MagicMock + ) -> None: """Test document preprocessing with spaCy backend""" # Mock spaCy processing mock_doc = Mock() mock_token = Mock() - mock_token.text = 'test' - mock_token.pos_ = 'NOUN' - mock_token.lemma_ = 'test' - + mock_token.text = "test" + mock_token.pos_ = "NOUN" + mock_token.lemma_ = "test" + mock_doc.__iter__ = Mock(return_value=iter([mock_token])) - + mock_nlp = Mock() mock_nlp.return_value = mock_doc mock_load_spacy.return_value = mock_nlp - - result = preprocess_document_content("test content", nlp_backend='spacy') - - assert 'enhanced_text' in result - assert 'keywords' in result + + result = preprocess_document_content("test content", nlp_backend="spacy") + + assert "enhanced_text" in result + assert "keywords" in result mock_load_spacy.assert_called_once() - + def test_preprocess_document_content_different_language(self) -> None: """Test document preprocessing with different language""" content = "Este es un documento de prueba." - result = preprocess_document_content(content, language='es') - - assert result['language'] == 'es' - assert 'enhanced_text' in result - assert 'keywords' in result - + result = preprocess_document_content(content, language="es") + + assert result["language"] == "es" + assert "enhanced_text" in result + assert "keywords" in result + def test_preprocess_document_content_empty(self) -> None: """Test document preprocessing with empty content""" result = preprocess_document_content("") - - assert result['enhanced_text'] == "" - assert result['language'] == 'en' - assert 'keywords' in result + + assert result["enhanced_text"] == "" + assert result["language"] == "en" + assert "keywords" in result class TestErrorHandling: """Test error handling in query processor""" - - @patch('signalwire.search.query_processor.nltk') + + @patch("signalwire.search.query_processor.nltk") def test_preprocess_query_nltk_error(self, mock_nltk: MagicMock) -> None: """Test query preprocessing when NLTK operations fail gracefully""" # Mock NLTK to work for most operations but fail for one - mock_nltk.word_tokenize.return_value = ['test', 'query'] + mock_nltk.word_tokenize.return_value = ["test", "query"] mock_nltk.corpus.stopwords.words.return_value = [] - mock_nltk.pos_tag.return_value = [('test', 'NN'), ('query', 'NN')] - mock_nltk.WordNetLemmatizer.return_value.lemmatize.return_value = 'test' - + mock_nltk.pos_tag.return_value = [("test", "NN"), ("query", "NN")] + mock_nltk.WordNetLemmatizer.return_value.lemmatize.return_value = "test" + # Should not crash, should handle gracefully result = preprocess_query("test query") - - assert 'enhanced_text' in result + + assert "enhanced_text" in result # Should fallback to basic processing - - @patch('signalwire.search.query_processor.vectorize_query') - def test_preprocess_query_vectorization_error(self, mock_vectorize: MagicMock) -> None: + + @patch("signalwire.search.query_processor.vectorize_query") + def test_preprocess_query_vectorization_error( + self, mock_vectorize: MagicMock + ) -> None: """Test query preprocessing when vectorization fails""" mock_vectorize.return_value = None # Simulate vectorization failure - + result = preprocess_query("test query", vector=True) - + # Should handle error gracefully - assert 'enhanced_text' in result - assert result.get('vector') is None \ No newline at end of file + assert "enhanced_text" in result + assert result.get("vector") is None diff --git a/tests/unit/search/test_search_engine.py b/tests/unit/search/test_search_engine.py index 6c9ff6bf..76432b52 100644 --- a/tests/unit/search/test_search_engine.py +++ b/tests/unit/search/test_search_engine.py @@ -11,11 +11,13 @@ Unit tests for search engine module """ +import os import pytest import sqlite3 import json import tempfile -import os +import importlib.util +from contextlib import closing from unittest.mock import Mock, patch, MagicMock from pathlib import Path @@ -25,88 +27,85 @@ class TestSearchEngineInit: """Test SearchEngine initialization""" - - def test_init_with_valid_index(self) -> None: + + def test_init_with_valid_index(self, tmp_path: Path) -> None: """Test initialization with valid index file""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp: - # Create a minimal database - conn = sqlite3.connect(tmp.name) + # `tmp_path`, not NamedTemporaryFile(delete=False): the latter keeps its own + # OS handle open for the life of the `with` block, and on Windows a file with + # a live handle cannot be unlinked (PermissionError/WinError 32). + db_path = str(tmp_path / "valid_index.db") + # Create a minimal database + with closing(sqlite3.connect(db_path)) as conn: cursor = conn.cursor() - cursor.execute(''' + cursor.execute(""" CREATE TABLE config (key TEXT, value TEXT) - ''') - cursor.execute(''' + """) + cursor.execute(""" INSERT INTO config (key, value) VALUES ('embedding_dimensions', '768') - ''') + """) conn.commit() - conn.close() - - engine = SearchEngine(backend='sqlite', index_path=tmp.name) - assert engine.index_path == tmp.name - assert engine.embedding_dim == 768 - assert engine.config['embedding_dimensions'] == '768' - os.unlink(tmp.name) + engine = SearchEngine(backend="sqlite", index_path=db_path) + assert engine.index_path == db_path + assert engine.embedding_dim == 768 + assert engine.config["embedding_dimensions"] == "768" - def test_init_with_missing_config(self) -> None: + def test_init_with_missing_config(self, tmp_path: Path) -> None: """Test initialization with missing config table""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp: - # Create empty database - conn = sqlite3.connect(tmp.name) - conn.close() + db_path = str(tmp_path / "missing_config.db") + # Create empty database + with closing(sqlite3.connect(db_path)): + pass - engine = SearchEngine(backend='sqlite', index_path=tmp.name) - assert engine.index_path == tmp.name - assert engine.embedding_dim == 768 # Default value - assert engine.config == {} - - os.unlink(tmp.name) + engine = SearchEngine(backend="sqlite", index_path=db_path) + assert engine.index_path == db_path + assert engine.embedding_dim == 768 # Default value + assert engine.config == {} def test_init_with_nonexistent_file(self) -> None: """Test initialization with nonexistent index file""" - engine = SearchEngine(backend='sqlite', index_path='/nonexistent/path.db') - assert engine.index_path == '/nonexistent/path.db' + engine = SearchEngine(backend="sqlite", index_path="/nonexistent/path.db") + assert engine.index_path == "/nonexistent/path.db" assert engine.embedding_dim == 768 # Default value assert engine.config == {} def test_init_with_custom_model(self) -> None: """Test initialization with custom model""" mock_model = Mock() - engine = SearchEngine(backend='sqlite', index_path='test.db', model=mock_model) + engine = SearchEngine(backend="sqlite", index_path="test.db", model=mock_model) assert engine.model == mock_model -try: - import numpy as _np - _has_numpy = True -except ImportError: - _has_numpy = False +_has_numpy = importlib.util.find_spec("numpy") is not None -@pytest.mark.skipif(not _has_numpy, reason='numpy not installed') +@pytest.mark.skipif(not _has_numpy, reason="numpy not installed") class TestSearchEngineVectorSearch: """Test vector search functionality""" def setup_method(self) -> None: """Set up test database""" - self.tmp_file = tempfile.NamedTemporaryFile(suffix='.db', delete=False) - self.db_path = self.tmp_file.name - self.tmp_file.close() - + # mkstemp, not NamedTemporaryFile(delete=False): we want the temp PATH, + # not a live handle. NamedTemporaryFile leaves its file object open until + # we close it (and would leak it if setup raised in between); mkstemp + # hands back a bare fd we close immediately. teardown_method unlinks. + fd, self.db_path = tempfile.mkstemp(suffix=".db") + os.close(fd) + # Create test database conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - + # Create config table - cursor.execute(''' + cursor.execute(""" CREATE TABLE config (key TEXT, value TEXT) - ''') - cursor.execute(''' + """) + cursor.execute(""" INSERT INTO config (key, value) VALUES ('embedding_dimensions', '384') - ''') - + """) + # Create chunks table with correct schema - cursor.execute(''' + cursor.execute(""" CREATE TABLE chunks ( id INTEGER PRIMARY KEY, content TEXT, @@ -118,95 +117,127 @@ def setup_method(self) -> None: language TEXT, processed_content TEXT ) - ''') - + """) + # Insert test data with embeddings import numpy as np + embedding1 = np.array([0.1, 0.2, 0.3], dtype=np.float32).tobytes() embedding2 = np.array([0.4, 0.5, 0.6], dtype=np.float32).tobytes() - - cursor.execute(''' + + cursor.execute( + """ INSERT INTO chunks (content, embedding, filename, section, tags, metadata, language, processed_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', ('Test content 1', embedding1, 'test1.md', 'intro', '["tag1"]', '{"key": "value1"}', 'en', 'test content 1')) - - cursor.execute(''' + """, + ( + "Test content 1", + embedding1, + "test1.md", + "intro", + '["tag1"]', + '{"key": "value1"}', + "en", + "test content 1", + ), + ) + + cursor.execute( + """ INSERT INTO chunks (content, embedding, filename, section, tags, metadata, language, processed_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', ('Test content 2', embedding2, 'test2.md', 'body', '["tag2"]', '{"key": "value2"}', 'en', 'test content 2')) - + """, + ( + "Test content 2", + embedding2, + "test2.md", + "body", + '["tag2"]', + '{"key": "value2"}', + "en", + "test content 2", + ), + ) + conn.commit() conn.close() - + def teardown_method(self) -> None: """Clean up test database""" - os.unlink(self.db_path) - - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_vector_search_success(self, mock_cosine_sim: MagicMock, mock_np: MagicMock) -> None: + Path(self.db_path).unlink() + + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_vector_search_success( + self, mock_cosine_sim: MagicMock, mock_np: MagicMock + ) -> None: """Test successful vector search""" # Mock numpy and cosine similarity query_array = Mock() query_array.reshape.return_value = [[0.1, 0.2, 0.3]] mock_np.array.return_value = query_array - + # Mock frombuffer to return proper arrays embedding1 = Mock() embedding1.reshape.return_value = [[0.1, 0.2, 0.3]] embedding2 = Mock() embedding2.reshape.return_value = [[0.4, 0.5, 0.6]] - + mock_np.frombuffer.side_effect = [embedding1, embedding2] mock_cosine_sim.side_effect = [ [[0.95]], # High similarity for first chunk - [[0.75]] # Lower similarity for second chunk + [[0.75]], # Lower similarity for second chunk ] - - engine = SearchEngine(backend='sqlite', index_path=self.db_path) + + engine = SearchEngine(backend="sqlite", index_path=self.db_path) results = engine._vector_search([[0.1, 0.2, 0.3]], count=2) assert len(results) == 2 - assert results[0]['score'] == 0.95 - assert results[0]['content'] == 'Test content 1' - assert results[0]['search_type'] == 'vector' - assert results[1]['score'] == 0.75 - assert results[1]['content'] == 'Test content 2' - - @patch('signalwire.search.search_engine.np', None) - @patch('signalwire.search.search_engine.cosine_similarity', None) + assert results[0]["score"] == 0.95 + assert results[0]["content"] == "Test content 1" + assert results[0]["search_type"] == "vector" + assert results[1]["score"] == 0.75 + assert results[1]["content"] == "Test content 2" + + @patch("signalwire.search.search_engine.np", None) + @patch("signalwire.search.search_engine.cosine_similarity", None) def test_vector_search_no_numpy(self) -> None: """Test vector search when numpy is not available""" - engine = SearchEngine(backend='sqlite', index_path=self.db_path) + engine = SearchEngine(backend="sqlite", index_path=self.db_path) results = engine._vector_search([[0.1, 0.2, 0.3]], count=2) assert results == [] - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_vector_search_database_error(self, mock_cosine_sim: MagicMock, mock_np: MagicMock) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_vector_search_database_error( + self, mock_cosine_sim: MagicMock, mock_np: MagicMock + ) -> None: """Test vector search with database error""" - engine = SearchEngine(backend='sqlite', index_path='/nonexistent/path.db') + engine = SearchEngine(backend="sqlite", index_path="/nonexistent/path.db") results = engine._vector_search([[0.1, 0.2, 0.3]], count=2) - + assert results == [] class TestSearchEngineKeywordSearch: """Test keyword search functionality""" - + def setup_method(self) -> None: """Set up test database with FTS""" - self.tmp_file = tempfile.NamedTemporaryFile(suffix='.db', delete=False) - self.db_path = self.tmp_file.name - self.tmp_file.close() - + # mkstemp, not NamedTemporaryFile(delete=False): we want the temp PATH, + # not a live handle. NamedTemporaryFile leaves its file object open until + # we close it (and would leak it if setup raised in between); mkstemp + # hands back a bare fd we close immediately. teardown_method unlinks. + fd, self.db_path = tempfile.mkstemp(suffix=".db") + os.close(fd) + # Create test database conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - + # Create chunks table - cursor.execute(''' + cursor.execute(""" CREATE TABLE chunks ( id INTEGER PRIMARY KEY, content TEXT, @@ -217,85 +248,112 @@ def setup_method(self) -> None: language TEXT, processed_content TEXT ) - ''') - + """) + # Create FTS table - cursor.execute(''' + cursor.execute(""" CREATE VIRTUAL TABLE chunks_fts USING fts5(content, content=chunks, content_rowid=id) - ''') - + """) + # Insert test data - cursor.execute(''' + cursor.execute( + """ INSERT INTO chunks (content, filename, section, tags, metadata, language, processed_content) VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ('Python programming tutorial', 'python.md', 'intro', '["python", "tutorial"]', '{"level": "beginner"}', 'en', 'python programming tutorial')) - - cursor.execute(''' + """, + ( + "Python programming tutorial", + "python.md", + "intro", + '["python", "tutorial"]', + '{"level": "beginner"}', + "en", + "python programming tutorial", + ), + ) + + cursor.execute( + """ INSERT INTO chunks (content, filename, section, tags, metadata, language, processed_content) VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ('Advanced Python concepts', 'advanced.md', 'body', '["python", "advanced"]', '{"level": "expert"}', 'en', 'advanced python concepts')) - + """, + ( + "Advanced Python concepts", + "advanced.md", + "body", + '["python", "advanced"]', + '{"level": "expert"}', + "en", + "advanced python concepts", + ), + ) + # Populate FTS index cursor.execute('INSERT INTO chunks_fts(chunks_fts) VALUES("rebuild")') - + conn.commit() conn.close() - + def teardown_method(self) -> None: """Clean up test database""" - os.unlink(self.db_path) - + Path(self.db_path).unlink() + def test_keyword_search_success(self) -> None: """Test successful keyword search""" - engine = SearchEngine(backend='sqlite', index_path=self.db_path) - results = engine._keyword_search('Python', count=2) - + engine = SearchEngine(backend="sqlite", index_path=self.db_path) + results = engine._keyword_search("Python", count=2) + assert len(results) == 2 - assert all('Python' in result['content'] for result in results) - assert all(result['search_type'] == 'keyword' for result in results) - assert all(isinstance(result['score'], float) for result in results) - + assert all("Python" in result["content"] for result in results) + assert all(result["search_type"] == "keyword" for result in results) + assert all(isinstance(result["score"], float) for result in results) + def test_keyword_search_no_results(self) -> None: """Test keyword search with no matching results""" - engine = SearchEngine(backend='sqlite', index_path=self.db_path) - results = engine._keyword_search('nonexistent', count=2) - + engine = SearchEngine(backend="sqlite", index_path=self.db_path) + results = engine._keyword_search("nonexistent", count=2) + assert results == [] - + def test_escape_fts_query(self) -> None: """Test FTS query escaping""" - engine = SearchEngine(backend='sqlite', index_path=self.db_path) + engine = SearchEngine(backend="sqlite", index_path=self.db_path) # New behavior: strips double quotes and wraps each term in double quotes assert engine._escape_fts_query('test "query"') == '"test" "query"' - assert engine._escape_fts_query('test*') == '"test*"' - assert engine._escape_fts_query('test AND query') == '"test" "AND" "query"' - + assert engine._escape_fts_query("test*") == '"test*"' + assert engine._escape_fts_query("test AND query") == '"test" "AND" "query"' + def test_keyword_search_database_error(self) -> None: """Test keyword search with database error""" - engine = SearchEngine(backend='sqlite', index_path='/nonexistent/path.db') - results = engine._keyword_search('Python', count=2) - + engine = SearchEngine(backend="sqlite", index_path="/nonexistent/path.db") + results = engine._keyword_search("Python", count=2) + assert results == [] class TestSearchEngineHybridSearch: """Test hybrid search functionality""" - + def setup_method(self) -> None: """Set up test database""" - self.tmp_file = tempfile.NamedTemporaryFile(suffix='.db', delete=False) - self.db_path = self.tmp_file.name - self.tmp_file.close() - + # mkstemp, not NamedTemporaryFile(delete=False): we want the temp PATH, + # not a live handle. NamedTemporaryFile leaves its file object open until + # we close it (and would leak it if setup raised in between); mkstemp + # hands back a bare fd we close immediately. teardown_method unlinks. + fd, self.db_path = tempfile.mkstemp(suffix=".db") + os.close(fd) + # Create minimal database for testing conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - cursor.execute('CREATE TABLE config (key TEXT, value TEXT)') - cursor.execute('INSERT INTO config (key, value) VALUES ("embedding_dimensions", "384")') - + cursor.execute("CREATE TABLE config (key TEXT, value TEXT)") + cursor.execute( + 'INSERT INTO config (key, value) VALUES ("embedding_dimensions", "384")' + ) + # Create chunks table for distance threshold test - cursor.execute(''' + cursor.execute(""" CREATE TABLE chunks ( id INTEGER PRIMARY KEY, content TEXT, @@ -306,245 +364,359 @@ def setup_method(self) -> None: language TEXT, processed_content TEXT ) - ''') - + """) + # Create FTS table - cursor.execute(''' + cursor.execute(""" CREATE VIRTUAL TABLE chunks_fts USING fts5(content, content=chunks, content_rowid=id) - ''') - + """) + # Insert test data - cursor.execute(''' + cursor.execute( + """ INSERT INTO chunks (content, filename, section, tags, metadata, language, processed_content) VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ('High score result', 'test.md', 'intro', '[]', '{}', 'en', 'high score result')) - - cursor.execute(''' + """, + ( + "High score result", + "test.md", + "intro", + "[]", + "{}", + "en", + "high score result", + ), + ) + + cursor.execute( + """ INSERT INTO chunks (content, filename, section, tags, metadata, language, processed_content) VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ('Low score result', 'test.md', 'body', '[]', '{}', 'en', 'low score result')) - + """, + ( + "Low score result", + "test.md", + "body", + "[]", + "{}", + "en", + "low score result", + ), + ) + # Populate FTS index cursor.execute('INSERT INTO chunks_fts(chunks_fts) VALUES("rebuild")') - + conn.commit() conn.close() - + def teardown_method(self) -> None: """Clean up test database""" - os.unlink(self.db_path) - - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_search_with_numpy_available(self, mock_cosine_sim: MagicMock, mock_np: MagicMock) -> None: + Path(self.db_path).unlink() + + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_search_with_numpy_available( + self, mock_cosine_sim: MagicMock, mock_np: MagicMock + ) -> None: """Test search when numpy is available""" - engine = SearchEngine(backend='sqlite', index_path=self.db_path) + engine = SearchEngine(backend="sqlite", index_path=self.db_path) # Mock all search methods used in the parallel search approach - engine._vector_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'content': 'Vector result', 'score': 0.9, 'search_type': 'vector', 'metadata': {}} - ]) + engine._vector_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 1, + "content": "Vector result", + "score": 0.9, + "search_type": "vector", + "metadata": {}, + } + ] + ) engine._filename_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._metadata_search = Mock(return_value=[]) # type: ignore[method-assign] # mock - engine._keyword_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 2, 'content': 'Keyword result', 'score': 0.8, 'search_type': 'keyword', 'metadata': {}} - ]) - engine._calculate_combined_score = Mock(side_effect=lambda c, t: c.get('score', 0.0)) # type: ignore[method-assign] # mock - engine._apply_diversity_penalties = Mock(side_effect=lambda results, count: results) # type: ignore[method-assign] # mock + engine._keyword_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 2, + "content": "Keyword result", + "score": 0.8, + "search_type": "keyword", + "metadata": {}, + } + ] + ) + engine._calculate_combined_score = Mock( # type: ignore[method-assign] # mock + side_effect=lambda c, t: c.get("score", 0.0) + ) + engine._apply_diversity_penalties = Mock( # type: ignore[method-assign] # mock + side_effect=lambda results, count: results + ) mock_np.array.return_value.reshape.return_value = [[0.1, 0.2, 0.3]] - results = engine.search([0.1, 0.2, 0.3], 'test query', count=2) + results = engine.search([0.1, 0.2, 0.3], "test query", count=2) engine._vector_search.assert_called_once() engine._keyword_search.assert_called_once() assert len(results) == 2 - - @patch('signalwire.search.search_engine.np', None) - @patch('signalwire.search.search_engine.cosine_similarity', None) + + @patch("signalwire.search.search_engine.np", None) + @patch("signalwire.search.search_engine.cosine_similarity", None) def test_search_without_numpy(self, mock_logger: MagicMock | None = None) -> None: """Test search when numpy is not available""" - engine = SearchEngine(backend='sqlite', index_path=self.db_path) - engine._keyword_search_only = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'content': 'Keyword only result', 'score': 0.8} - ]) + engine = SearchEngine(backend="sqlite", index_path=self.db_path) + engine._keyword_search_only = Mock( # type: ignore[method-assign] # mock + return_value=[{"id": 1, "content": "Keyword only result", "score": 0.8}] + ) - results = engine.search([0.1, 0.2, 0.3], 'test query', count=2) + results = engine.search([0.1, 0.2, 0.3], "test query", count=2) - engine._keyword_search_only.assert_called_once_with('test query', 2, None, None) + engine._keyword_search_only.assert_called_once_with("test query", 2, None, None) assert len(results) == 1 - - @patch('signalwire.search.search_engine.np', None) - @patch('signalwire.search.search_engine.cosine_similarity', None) + + @patch("signalwire.search.search_engine.np", None) + @patch("signalwire.search.search_engine.cosine_similarity", None) def test_search_with_tags_filter(self) -> None: """Test search with tag filtering""" - engine = SearchEngine(backend='sqlite', index_path=self.db_path) + engine = SearchEngine(backend="sqlite", index_path=self.db_path) # When numpy is not available, search() delegates to _keyword_search_only # which handles tag filtering internally - engine._keyword_search_only = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'content': 'Result 1', 'score': 0.9, 'metadata': {'tags': ['python', 'tutorial']}} - ]) + engine._keyword_search_only = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 1, + "content": "Result 1", + "score": 0.9, + "metadata": {"tags": ["python", "tutorial"]}, + } + ] + ) - results = engine.search([0.1, 0.2, 0.3], 'test query', count=2, tags=['python']) + results = engine.search([0.1, 0.2, 0.3], "test query", count=2, tags=["python"]) - engine._keyword_search_only.assert_called_once_with('test query', 2, ['python'], None) + engine._keyword_search_only.assert_called_once_with( + "test query", 2, ["python"], None + ) assert len(results) == 1 - - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_search_with_similarity_threshold(self, mock_cosine_sim: MagicMock, mock_np: MagicMock) -> None: + + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_search_with_similarity_threshold( + self, mock_cosine_sim: MagicMock, mock_np: MagicMock + ) -> None: """Test search with distance threshold filtering""" - engine = SearchEngine(backend='sqlite', index_path=self.db_path) + engine = SearchEngine(backend="sqlite", index_path=self.db_path) # Mock numpy to be available mock_np.array.return_value.reshape.return_value = [[0.1, 0.2, 0.3]] # Both results come via vector search so the distance threshold filter applies - engine._vector_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'content': 'High score result', 'score': 0.9, 'search_type': 'vector', 'metadata': {}}, - {'id': 2, 'content': 'Low score result', 'score': 0.3, 'search_type': 'vector', 'metadata': {}}, - ]) + engine._vector_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 1, + "content": "High score result", + "score": 0.9, + "search_type": "vector", + "metadata": {}, + }, + { + "id": 2, + "content": "Low score result", + "score": 0.3, + "search_type": "vector", + "metadata": {}, + }, + ] + ) engine._filename_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._metadata_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._keyword_search = Mock(return_value=[]) # type: ignore[method-assign] # mock # Mock scoring: score is passed through def mock_combined_score(candidate: dict[str, Any], threshold: float) -> float: - return float(candidate.get('score', 0.0)) + return float(candidate.get("score", 0.0)) + engine._calculate_combined_score = Mock(side_effect=mock_combined_score) # type: ignore[method-assign] # mock - engine._apply_diversity_penalties = Mock(side_effect=lambda results, count: results) # type: ignore[method-assign] # mock + engine._apply_diversity_penalties = Mock( # type: ignore[method-assign] # mock + side_effect=lambda results, count: results + ) - results = engine.search([0.1, 0.2, 0.3], 'test query', count=2, similarity_threshold=0.5) + results = engine.search( + [0.1, 0.2, 0.3], "test query", count=2, similarity_threshold=0.5 + ) # The low score vector result has vector_distance = 1 - 0.3 = 0.7 # The threshold filter keeps results where vector_distance <= 0.5 * 1.5 = 0.75 # Both results pass (0.1 <= 0.75 and 0.7 <= 0.75), but high score is first assert len(results) >= 1 - assert results[0]['score'] >= 0.9 + assert results[0]["score"] >= 0.9 class TestSearchEngineUtilities: """Test utility methods""" - + def setup_method(self) -> None: """Set up test database""" - self.tmp_file = tempfile.NamedTemporaryFile(suffix='.db', delete=False) - self.db_path = self.tmp_file.name - self.tmp_file.close() - + # mkstemp, not NamedTemporaryFile(delete=False): we want the temp PATH, + # not a live handle. NamedTemporaryFile leaves its file object open until + # we close it (and would leak it if setup raised in between); mkstemp + # hands back a bare fd we close immediately. teardown_method unlinks. + fd, self.db_path = tempfile.mkstemp(suffix=".db") + os.close(fd) + # Create test database with stats conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - - cursor.execute('CREATE TABLE config (key TEXT, value TEXT)') - cursor.execute('INSERT INTO config (key, value) VALUES ("embedding_dimensions", "384")') - - cursor.execute(''' + + cursor.execute("CREATE TABLE config (key TEXT, value TEXT)") + cursor.execute( + 'INSERT INTO config (key, value) VALUES ("embedding_dimensions", "384")' + ) + + cursor.execute(""" CREATE TABLE chunks ( id INTEGER PRIMARY KEY, content TEXT, filename TEXT, language TEXT ) - ''') - + """) + # Insert test data - cursor.execute('INSERT INTO chunks (content, filename, language) VALUES (?, ?, ?)', ('Content 1', 'file1.md', 'en')) - cursor.execute('INSERT INTO chunks (content, filename, language) VALUES (?, ?, ?)', ('Content 2', 'file1.md', 'en')) - cursor.execute('INSERT INTO chunks (content, filename, language) VALUES (?, ?, ?)', ('Content 3', 'file2.py', 'en')) - + cursor.execute( + "INSERT INTO chunks (content, filename, language) VALUES (?, ?, ?)", + ("Content 1", "file1.md", "en"), + ) + cursor.execute( + "INSERT INTO chunks (content, filename, language) VALUES (?, ?, ?)", + ("Content 2", "file1.md", "en"), + ) + cursor.execute( + "INSERT INTO chunks (content, filename, language) VALUES (?, ?, ?)", + ("Content 3", "file2.py", "en"), + ) + conn.commit() conn.close() - + def teardown_method(self) -> None: """Clean up test database""" - os.unlink(self.db_path) - + Path(self.db_path).unlink() + def test_get_stats_success(self) -> None: """Test getting index statistics""" - engine = SearchEngine(backend='sqlite', index_path=self.db_path) + engine = SearchEngine(backend="sqlite", index_path=self.db_path) stats = engine.get_stats() - - assert stats['total_chunks'] == 3 - assert stats['total_files'] == 2 - assert stats['config']['embedding_dimensions'] == '384' - assert 'file_types' in stats - assert 'languages' in stats - + + assert stats["total_chunks"] == 3 + assert stats["total_files"] == 2 + assert stats["config"]["embedding_dimensions"] == "384" + assert "file_types" in stats + assert "languages" in stats + def test_get_stats_database_error(self) -> None: """Test getting stats with database error""" # Mock the get_stats method to avoid the actual database connection error - engine = SearchEngine(backend='sqlite', index_path='/nonexistent/path.db') - - with patch.object(engine, 'get_stats', return_value={}): + engine = SearchEngine(backend="sqlite", index_path="/nonexistent/path.db") + + with patch.object(engine, "get_stats", return_value={}): stats = engine.get_stats() assert stats == {} - + def test_merge_results(self) -> None: """Test merging vector and keyword results""" - engine = SearchEngine(backend='sqlite', index_path=self.db_path) - + engine = SearchEngine(backend="sqlite", index_path=self.db_path) + vector_results = [ - {'id': 1, 'content': 'Result 1', 'score': 0.9, 'search_type': 'vector', 'metadata': {}}, - {'id': 2, 'content': 'Result 2', 'score': 0.7, 'search_type': 'vector', 'metadata': {}} + { + "id": 1, + "content": "Result 1", + "score": 0.9, + "search_type": "vector", + "metadata": {}, + }, + { + "id": 2, + "content": "Result 2", + "score": 0.7, + "search_type": "vector", + "metadata": {}, + }, ] - + keyword_results = [ - {'id': 2, 'content': 'Result 2', 'score': 0.8, 'search_type': 'keyword', 'metadata': {}}, - {'id': 3, 'content': 'Result 3', 'score': 0.6, 'search_type': 'keyword', 'metadata': {}} + { + "id": 2, + "content": "Result 2", + "score": 0.8, + "search_type": "keyword", + "metadata": {}, + }, + { + "id": 3, + "content": "Result 3", + "score": 0.6, + "search_type": "keyword", + "metadata": {}, + }, ] - + merged = engine._merge_results(vector_results, keyword_results) - + # Should combine scores for duplicate IDs and sort by final score assert len(merged) == 3 - assert merged[0]['id'] == 2 # Highest combined score - assert merged[0]['score'] > 0.7 # Combined vector + keyword score (0.7*0.7 + 0.3*0.8 = 0.73) - assert 'search_scores' in merged[0]['metadata'] - + assert merged[0]["id"] == 2 # Highest combined score + assert ( + merged[0]["score"] > 0.7 + ) # Combined vector + keyword score (0.7*0.7 + 0.3*0.8 = 0.73) + assert "search_scores" in merged[0]["metadata"] + def test_filter_by_tags(self) -> None: """Test filtering results by tags""" - engine = SearchEngine(backend='sqlite', index_path=self.db_path) - + engine = SearchEngine(backend="sqlite", index_path=self.db_path) + results = [ - {'id': 1, 'metadata': {'tags': ['python', 'tutorial']}}, - {'id': 2, 'metadata': {'tags': ['javascript', 'tutorial']}}, - {'id': 3, 'metadata': {'tags': ['python', 'advanced']}} + {"id": 1, "metadata": {"tags": ["python", "tutorial"]}}, + {"id": 2, "metadata": {"tags": ["javascript", "tutorial"]}}, + {"id": 3, "metadata": {"tags": ["python", "advanced"]}}, ] - - filtered = engine._filter_by_tags(results, ['python']) - + + filtered = engine._filter_by_tags(results, ["python"]) + assert len(filtered) == 2 - assert all('python' in result['metadata']['tags'] for result in filtered) - + assert all("python" in result["metadata"]["tags"] for result in filtered) + def test_filter_by_tags_no_metadata(self) -> None: """Test filtering when results have no metadata""" - engine = SearchEngine(backend='sqlite', index_path=self.db_path) - + engine = SearchEngine(backend="sqlite", index_path=self.db_path) + results = [ - {'id': 1, 'metadata': {}}, - {'id': 2, 'metadata': {'tags': ['python']}} + {"id": 1, "metadata": {}}, + {"id": 2, "metadata": {"tags": ["python"]}}, ] - - filtered = engine._filter_by_tags(results, ['python']) - + + filtered = engine._filter_by_tags(results, ["python"]) + assert len(filtered) == 1 - assert filtered[0]['id'] == 2 + assert filtered[0]["id"] == 2 class TestSearchEngineEdgeCases: """Test edge cases and error handling""" - - def test_fallback_search(self) -> None: + + def test_fallback_search(self, tmp_path: Path) -> None: """Test fallback search functionality""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp: - # Create database with fallback search capability - conn = sqlite3.connect(tmp.name) + db_path = str(tmp_path / "fallback.db") + # Create database with fallback search capability + with closing(sqlite3.connect(db_path)) as conn: cursor = conn.cursor() - - cursor.execute('CREATE TABLE config (key TEXT, value TEXT)') - - cursor.execute(''' + + cursor.execute("CREATE TABLE config (key TEXT, value TEXT)") + + cursor.execute(""" CREATE TABLE chunks ( id INTEGER PRIMARY KEY, content TEXT, @@ -554,46 +726,55 @@ def test_fallback_search(self) -> None: metadata TEXT, processed_content TEXT ) - ''') - - cursor.execute(''' + """) + + cursor.execute( + """ INSERT INTO chunks (content, filename, section, tags, metadata, processed_content) VALUES (?, ?, ?, ?, ?, ?) - ''', ('Python tutorial content', 'tutorial.md', 'intro', '["python"]', '{}', 'python tutorial content')) - + """, + ( + "Python tutorial content", + "tutorial.md", + "intro", + '["python"]', + "{}", + "python tutorial content", + ), + ) + conn.commit() - conn.close() - - engine = SearchEngine(backend='sqlite', index_path=tmp.name) - results = engine._fallback_search('Python', count=1) - - assert len(results) == 1 - assert 'Python' in results[0]['content'] - assert results[0]['search_type'] == 'fallback' - - os.unlink(tmp.name) - + + engine = SearchEngine(backend="sqlite", index_path=db_path) + results = engine._fallback_search("Python", count=1) + + assert len(results) == 1 + assert "Python" in results[0]["content"] + assert results[0]["search_type"] == "fallback" + def test_fallback_search_database_error(self) -> None: """Test fallback search with database error""" - engine = SearchEngine(backend='sqlite', index_path='/nonexistent/path.db') - results = engine._fallback_search('Python', count=1) - + engine = SearchEngine(backend="sqlite", index_path="/nonexistent/path.db") + results = engine._fallback_search("Python", count=1) + assert results == [] - + def test_keyword_search_only_with_tags(self) -> None: """Test keyword-only search with tag filtering""" - engine = SearchEngine(backend='sqlite', index_path='test.db') - engine._keyword_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'metadata': {'tags': ['python']}}, - {'id': 2, 'metadata': {'tags': ['javascript']}} - ]) - engine._filter_by_tags = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'metadata': {'tags': ['python']}} - ]) - - results = engine._keyword_search_only('test', count=2, tags=['python']) - - engine._keyword_search.assert_called_once_with('test', 2, None) + engine = SearchEngine(backend="sqlite", index_path="test.db") + engine._keyword_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + {"id": 1, "metadata": {"tags": ["python"]}}, + {"id": 2, "metadata": {"tags": ["javascript"]}}, + ] + ) + engine._filter_by_tags = Mock( # type: ignore[method-assign] # mock + return_value=[{"id": 1, "metadata": {"tags": ["python"]}}] + ) + + results = engine._keyword_search_only("test", count=2, tags=["python"]) + + engine._keyword_search.assert_called_once_with("test", 2, None) engine._filter_by_tags.assert_called_once() assert len(results) == 1 @@ -602,21 +783,27 @@ def test_keyword_search_only_with_tags(self) -> None: # Shared helpers for new tests # --------------------------------------------------------------------------- -def _create_full_test_db(db_path: str, with_fts: bool = True, with_embeddings: bool = True, - with_metadata_text: bool = False, - extra_chunks: list[dict[str, Any]] | None = None) -> str: + +def _create_full_test_db( + db_path: str, + with_fts: bool = True, + with_embeddings: bool = True, + with_metadata_text: bool = False, + extra_chunks: list[dict[str, Any]] | None = None, +) -> str: """Create a fully populated test database for search engine tests. Returns the path unchanged (for convenience). """ - import struct conn = sqlite3.connect(db_path) cursor = conn.cursor() # Config cursor.execute("CREATE TABLE IF NOT EXISTS config (key TEXT, value TEXT)") - cursor.execute("INSERT INTO config (key, value) VALUES ('embedding_dimensions', '4')") + cursor.execute( + "INSERT INTO config (key, value) VALUES ('embedding_dimensions', '4')" + ) # Chunks cols = """ @@ -646,75 +833,108 @@ def _emb(values: list[float]) -> bytes | None: if not with_embeddings: return None import struct as _s - return _s.pack(f'{len(values)}f', *values) + + return _s.pack(f"{len(values)}f", *values) # Default chunks default_chunks = [ { - 'content': 'Python programming tutorial for beginners', - 'embedding': _emb([0.9, 0.1, 0.0, 0.0]), - 'filename': 'docs/python_tutorial.md', - 'section': 'introduction', - 'tags': json.dumps(['python', 'tutorial', 'beginner']), - 'metadata': json.dumps({'category': 'Tutorial', 'product': 'Python SDK', 'source': 'python_tutorial.md'}), - 'language': 'en', - 'processed_content': 'python programming tutorial beginners', - 'metadata_text': 'python tutorial beginner Tutorial Python SDK' if with_metadata_text else None, + "content": "Python programming tutorial for beginners", + "embedding": _emb([0.9, 0.1, 0.0, 0.0]), + "filename": "docs/python_tutorial.md", + "section": "introduction", + "tags": json.dumps(["python", "tutorial", "beginner"]), + "metadata": json.dumps( + { + "category": "Tutorial", + "product": "Python SDK", + "source": "python_tutorial.md", + } + ), + "language": "en", + "processed_content": "python programming tutorial beginners", + "metadata_text": "python tutorial beginner Tutorial Python SDK" + if with_metadata_text + else None, }, { - 'content': 'Advanced Python decorators and metaclasses', - 'embedding': _emb([0.8, 0.2, 0.1, 0.0]), - 'filename': 'docs/advanced_python.md', - 'section': 'decorators', - 'tags': json.dumps(['python', 'advanced', 'code']), - 'metadata': json.dumps({'category': 'Code Examples', 'product': 'Python SDK', 'source': 'advanced_python.md'}), - 'language': 'en', - 'processed_content': 'advanced python decorators metaclasses', - 'metadata_text': 'python advanced code Code Examples Python SDK' if with_metadata_text else None, + "content": "Advanced Python decorators and metaclasses", + "embedding": _emb([0.8, 0.2, 0.1, 0.0]), + "filename": "docs/advanced_python.md", + "section": "decorators", + "tags": json.dumps(["python", "advanced", "code"]), + "metadata": json.dumps( + { + "category": "Code Examples", + "product": "Python SDK", + "source": "advanced_python.md", + } + ), + "language": "en", + "processed_content": "advanced python decorators metaclasses", + "metadata_text": "python advanced code Code Examples Python SDK" + if with_metadata_text + else None, }, { - 'content': 'JavaScript async await patterns and promises', - 'embedding': _emb([0.0, 0.0, 0.9, 0.1]), - 'filename': 'docs/javascript_guide.md', - 'section': 'async', - 'tags': json.dumps(['javascript', 'async']), - 'metadata': json.dumps({'category': 'Guide', 'product': 'JS SDK'}), - 'language': 'en', - 'processed_content': 'javascript async await patterns promises', - 'metadata_text': 'javascript async Guide JS SDK' if with_metadata_text else None, + "content": "JavaScript async await patterns and promises", + "embedding": _emb([0.0, 0.0, 0.9, 0.1]), + "filename": "docs/javascript_guide.md", + "section": "async", + "tags": json.dumps(["javascript", "async"]), + "metadata": json.dumps({"category": "Guide", "product": "JS SDK"}), + "language": "en", + "processed_content": "javascript async await patterns promises", + "metadata_text": "javascript async Guide JS SDK" + if with_metadata_text + else None, }, { - 'content': 'Getting started with SignalWire SDK', - 'embedding': _emb([0.3, 0.3, 0.3, 0.3]), - 'filename': 'docs/getting_started.md', - 'section': 'quickstart', - 'tags': json.dumps(['signalwire', 'getting-started']), - 'metadata': json.dumps({'category': 'Getting Started', 'product': 'AI Agents SDK', 'description': 'Quickstart guide for AI Agents'}), - 'language': 'en', - 'processed_content': 'getting started signalwire ai agents sdk', - 'metadata_text': 'signalwire getting-started Getting Started AI Agents SDK' if with_metadata_text else None, + "content": "Getting started with SignalWire SDK", + "embedding": _emb([0.3, 0.3, 0.3, 0.3]), + "filename": "docs/getting_started.md", + "section": "quickstart", + "tags": json.dumps(["signalwire", "getting-started"]), + "metadata": json.dumps( + { + "category": "Getting Started", + "product": "AI Agents SDK", + "description": "Quickstart guide for AI Agents", + } + ), + "language": "en", + "processed_content": "getting started signalwire ai agents sdk", + "metadata_text": "signalwire getting-started Getting Started AI Agents SDK" + if with_metadata_text + else None, }, { - 'content': 'Code examples for REST API integration', - 'embedding': _emb([0.1, 0.8, 0.1, 0.0]), - 'filename': 'examples/rest_api_example.py', - 'section': 'examples', - 'tags': json.dumps(['code', 'api', 'example']), - 'metadata': json.dumps({'category': 'Code Examples', 'product': 'REST API'}), - 'language': 'en', - 'processed_content': 'code examples rest api integration', - 'metadata_text': 'code api example Code Examples REST API' if with_metadata_text else None, + "content": "Code examples for REST API integration", + "embedding": _emb([0.1, 0.8, 0.1, 0.0]), + "filename": "examples/rest_api_example.py", + "section": "examples", + "tags": json.dumps(["code", "api", "example"]), + "metadata": json.dumps( + {"category": "Code Examples", "product": "REST API"} + ), + "language": "en", + "processed_content": "code examples rest api integration", + "metadata_text": "code api example Code Examples REST API" + if with_metadata_text + else None, }, { - 'content': 'SignalWire SWML reference documentation', - 'embedding': _emb([0.2, 0.2, 0.2, 0.8]), - 'filename': 'docs/swml_reference.md', - 'section': 'reference', - 'tags': json.dumps(['swml', 'reference']), - 'metadata': json.dumps({'category': 'Reference', 'product': 'SWML'}), - 'language': 'en', - 'processed_content': 'signalwire swml reference documentation', - 'metadata_text': 'swml reference Reference SWML' if with_metadata_text else None, + "content": "SignalWire SWML reference documentation", + "embedding": _emb([0.2, 0.2, 0.2, 0.8]), + "filename": "docs/swml_reference.md", + "section": "reference", + "tags": json.dumps(["swml", "reference"]), + "metadata": json.dumps({"category": "Reference", "product": "SWML"}), + "language": "en", + "processed_content": "signalwire swml reference documentation", + "metadata_text": "swml reference Reference SWML" + if with_metadata_text + else None, }, ] @@ -726,16 +946,32 @@ def _emb(values: list[float]) -> bytes | None: cursor.execute( "INSERT INTO chunks (content, embedding, filename, section, tags, metadata, language, processed_content, metadata_text) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - (c['content'], c['embedding'], c['filename'], c['section'], - c['tags'], c['metadata'], c['language'], c['processed_content'], - c.get('metadata_text')) + ( + c["content"], + c["embedding"], + c["filename"], + c["section"], + c["tags"], + c["metadata"], + c["language"], + c["processed_content"], + c.get("metadata_text"), + ), ) else: cursor.execute( "INSERT INTO chunks (content, embedding, filename, section, tags, metadata, language, processed_content) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (c['content'], c['embedding'], c['filename'], c['section'], - c['tags'], c['metadata'], c['language'], c['processed_content']) + ( + c["content"], + c["embedding"], + c["filename"], + c["section"], + c["tags"], + c["metadata"], + c["language"], + c["processed_content"], + ), ) # Rebuild FTS @@ -792,7 +1028,9 @@ def empty_db(tmp_path: Path) -> str: filename TEXT, section TEXT, tags TEXT, metadata TEXT, language TEXT, processed_content TEXT )""") - c.execute("CREATE VIRTUAL TABLE chunks_fts USING fts5(content, content=chunks, content_rowid=id)") + c.execute( + "CREATE VIRTUAL TABLE chunks_fts USING fts5(content, content=chunks, content_rowid=id)" + ) conn.commit() conn.close() return db_path @@ -802,275 +1040,426 @@ def empty_db(tmp_path: Path) -> str: # TestSearch: End-to-end search() method # --------------------------------------------------------------------------- + class TestSearch: """End-to-end tests for the search() orchestrator method.""" - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_search_returns_results(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_search_returns_results( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """search() returns results when vector + keyword candidates exist.""" mock_np.array.return_value.reshape.return_value = [[0.9, 0.1, 0.0, 0.0]] - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) # Stub sub-searches to isolate orchestrator logic - engine._vector_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'content': 'Python programming tutorial for beginners', - 'score': 0.95, 'search_type': 'vector', - 'metadata': {'filename': 'docs/python_tutorial.md', 'section': 'introduction', 'tags': ['python'], 'metadata': {}}} - ]) + engine._vector_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 1, + "content": "Python programming tutorial for beginners", + "score": 0.95, + "search_type": "vector", + "metadata": { + "filename": "docs/python_tutorial.md", + "section": "introduction", + "tags": ["python"], + "metadata": {}, + }, + } + ] + ) engine._filename_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._metadata_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._keyword_search = Mock(return_value=[]) # type: ignore[method-assign] # mock - engine._calculate_combined_score = Mock(side_effect=lambda c, t: c.get('score', 0.0)) # type: ignore[method-assign] # mock + engine._calculate_combined_score = Mock( # type: ignore[method-assign] # mock + side_effect=lambda c, t: c.get("score", 0.0) + ) engine._apply_diversity_penalties = Mock(side_effect=lambda r, c: r) # type: ignore[method-assign] # mock - results = engine.search([0.9, 0.1, 0.0, 0.0], 'python tutorial', count=3) + results = engine.search([0.9, 0.1, 0.0, 0.0], "python tutorial", count=3) assert len(results) >= 1 - assert 'score' in results[0] + assert "score" in results[0] - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_search_merges_multiple_sources(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_search_merges_multiple_sources( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """search() merges candidates from vector, keyword, filename, metadata.""" mock_np.array.return_value.reshape.return_value = [[0.9, 0.1, 0.0, 0.0]] - engine = SearchEngine(backend='sqlite', index_path=full_db) - engine._vector_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'content': 'A', 'score': 0.9, 'search_type': 'vector', 'metadata': {'filename': 'a.md', 'tags': []}} - ]) - engine._filename_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 2, 'content': 'B', 'score': 0.8, 'search_type': 'filename', 'metadata': {'filename': 'b.md', 'tags': []}} - ]) + engine = SearchEngine(backend="sqlite", index_path=full_db) + engine._vector_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 1, + "content": "A", + "score": 0.9, + "search_type": "vector", + "metadata": {"filename": "a.md", "tags": []}, + } + ] + ) + engine._filename_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 2, + "content": "B", + "score": 0.8, + "search_type": "filename", + "metadata": {"filename": "b.md", "tags": []}, + } + ] + ) engine._metadata_search = Mock(return_value=[]) # type: ignore[method-assign] # mock - engine._keyword_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 3, 'content': 'C', 'score': 0.7, 'search_type': 'keyword', 'metadata': {'filename': 'c.md', 'tags': []}} - ]) - engine._calculate_combined_score = Mock(side_effect=lambda c, t: c.get('score', 0.0)) # type: ignore[method-assign] # mock + engine._keyword_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 3, + "content": "C", + "score": 0.7, + "search_type": "keyword", + "metadata": {"filename": "c.md", "tags": []}, + } + ] + ) + engine._calculate_combined_score = Mock( # type: ignore[method-assign] # mock + side_effect=lambda c, t: c.get("score", 0.0) + ) engine._apply_diversity_penalties = Mock(side_effect=lambda r, c: r) # type: ignore[method-assign] # mock - results = engine.search([0.9, 0.1, 0.0, 0.0], 'test', count=5) - ids = {r['id'] for r in results} + results = engine.search([0.9, 0.1, 0.0, 0.0], "test", count=5) + ids = {r["id"] for r in results} assert ids == {1, 2, 3} - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_search_respects_count(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_search_respects_count( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """search() returns at most `count` results.""" mock_np.array.return_value.reshape.return_value = [[0.9, 0.1, 0.0, 0.0]] - engine = SearchEngine(backend='sqlite', index_path=full_db) - many = [{'id': i, 'content': f'C{i}', 'score': 1.0 - i * 0.01, - 'search_type': 'vector', 'metadata': {'filename': f'f{i}.md', 'tags': []}} - for i in range(10)] + engine = SearchEngine(backend="sqlite", index_path=full_db) + many = [ + { + "id": i, + "content": f"C{i}", + "score": 1.0 - i * 0.01, + "search_type": "vector", + "metadata": {"filename": f"f{i}.md", "tags": []}, + } + for i in range(10) + ] engine._vector_search = Mock(return_value=many) # type: ignore[method-assign] # mock engine._filename_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._metadata_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._keyword_search = Mock(return_value=[]) # type: ignore[method-assign] # mock - engine._calculate_combined_score = Mock(side_effect=lambda c, t: c.get('score', 0.0)) # type: ignore[method-assign] # mock + engine._calculate_combined_score = Mock( # type: ignore[method-assign] # mock + side_effect=lambda c, t: c.get("score", 0.0) + ) engine._apply_diversity_penalties = Mock(side_effect=lambda r, c: r) # type: ignore[method-assign] # mock - results = engine.search([0.9, 0.1, 0.0, 0.0], 'test', count=3) + results = engine.search([0.9, 0.1, 0.0, 0.0], "test", count=3) assert len(results) == 3 - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_search_filters_by_tags(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_search_filters_by_tags( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """search() filters results by tags when specified.""" mock_np.array.return_value.reshape.return_value = [[0.9, 0.1, 0.0, 0.0]] - engine = SearchEngine(backend='sqlite', index_path=full_db) - engine._vector_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'content': 'A', 'score': 0.9, 'search_type': 'vector', - 'metadata': {'filename': 'a.md', 'tags': ['python'], 'metadata': {}}}, - {'id': 2, 'content': 'B', 'score': 0.8, 'search_type': 'vector', - 'metadata': {'filename': 'b.md', 'tags': ['javascript'], 'metadata': {}}}, - ]) + engine = SearchEngine(backend="sqlite", index_path=full_db) + engine._vector_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 1, + "content": "A", + "score": 0.9, + "search_type": "vector", + "metadata": { + "filename": "a.md", + "tags": ["python"], + "metadata": {}, + }, + }, + { + "id": 2, + "content": "B", + "score": 0.8, + "search_type": "vector", + "metadata": { + "filename": "b.md", + "tags": ["javascript"], + "metadata": {}, + }, + }, + ] + ) engine._filename_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._metadata_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._keyword_search = Mock(return_value=[]) # type: ignore[method-assign] # mock - engine._calculate_combined_score = Mock(side_effect=lambda c, t: c.get('score', 0.0)) # type: ignore[method-assign] # mock + engine._calculate_combined_score = Mock( # type: ignore[method-assign] # mock + side_effect=lambda c, t: c.get("score", 0.0) + ) engine._apply_diversity_penalties = Mock(side_effect=lambda r, c: r) # type: ignore[method-assign] # mock - results = engine.search([0.9, 0.1, 0.0, 0.0], 'test', count=5, tags=['python']) - assert all('python' in r['metadata']['tags'] for r in results) + results = engine.search([0.9, 0.1, 0.0, 0.0], "test", count=5, tags=["python"]) + assert all("python" in r["metadata"]["tags"] for r in results) - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_search_boosts_exact_matches(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_search_boosts_exact_matches( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """search() boosts exact query matches when original_query given.""" mock_np.array.return_value.reshape.return_value = [[0.9, 0.1, 0.0, 0.0]] - engine = SearchEngine(backend='sqlite', index_path=full_db) - engine._vector_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'content': 'python tutorial content', 'score': 0.5, - 'search_type': 'vector', 'metadata': {'filename': 'a.md', 'tags': [], 'metadata': {}}}, - {'id': 2, 'content': 'unrelated stuff', 'score': 0.6, - 'search_type': 'vector', 'metadata': {'filename': 'b.md', 'tags': [], 'metadata': {}}}, - ]) + engine = SearchEngine(backend="sqlite", index_path=full_db) + engine._vector_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 1, + "content": "python tutorial content", + "score": 0.5, + "search_type": "vector", + "metadata": {"filename": "a.md", "tags": [], "metadata": {}}, + }, + { + "id": 2, + "content": "unrelated stuff", + "score": 0.6, + "search_type": "vector", + "metadata": {"filename": "b.md", "tags": [], "metadata": {}}, + }, + ] + ) engine._filename_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._metadata_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._keyword_search = Mock(return_value=[]) # type: ignore[method-assign] # mock - engine._calculate_combined_score = Mock(side_effect=lambda c, t: c.get('score', 0.0)) # type: ignore[method-assign] # mock + engine._calculate_combined_score = Mock( # type: ignore[method-assign] # mock + side_effect=lambda c, t: c.get("score", 0.0) + ) engine._apply_diversity_penalties = Mock(side_effect=lambda r, c: r) # type: ignore[method-assign] # mock - results = engine.search([0.9, 0.1, 0.0, 0.0], 'python tutorial', - count=5, original_query='python tutorial') + results = engine.search( + [0.9, 0.1, 0.0, 0.0], + "python tutorial", + count=5, + original_query="python tutorial", + ) # The exact-match result should now be ranked first - assert results[0]['id'] == 1 + assert results[0]["id"] == 1 - @patch('signalwire.search.search_engine.np', None) - @patch('signalwire.search.search_engine.cosine_similarity', None) + @patch("signalwire.search.search_engine.np", None) + @patch("signalwire.search.search_engine.cosine_similarity", None) def test_search_falls_back_to_keyword_only(self, full_db: str) -> None: """search() uses keyword-only when numpy is unavailable.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - engine._keyword_search_only = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'content': 'result', 'score': 0.5} - ]) - results = engine.search([0.1], 'python', count=3) + engine = SearchEngine(backend="sqlite", index_path=full_db) + engine._keyword_search_only = Mock( # type: ignore[method-assign] # mock + return_value=[{"id": 1, "content": "result", "score": 0.5}] + ) + results = engine.search([0.1], "python", count=3) engine._keyword_search_only.assert_called_once() assert len(results) == 1 - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_search_handles_vector_conversion_error(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_search_handles_vector_conversion_error( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """search() falls back to keyword-only when vector conversion fails.""" mock_np.array.side_effect = Exception("bad vector") - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) engine._keyword_search_only = Mock(return_value=[]) # type: ignore[method-assign] # mock - results = engine.search([0.1], 'python', count=3) + engine.search([0.1], "python", count=3) engine._keyword_search_only.assert_called_once() - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_search_applies_diversity_penalties(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_search_applies_diversity_penalties( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """search() calls _apply_diversity_penalties.""" mock_np.array.return_value.reshape.return_value = [[0.9, 0.1, 0.0, 0.0]] - engine = SearchEngine(backend='sqlite', index_path=full_db) - engine._vector_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'content': 'A', 'score': 0.9, 'search_type': 'vector', - 'metadata': {'filename': 'a.md', 'tags': []}} - ]) + engine = SearchEngine(backend="sqlite", index_path=full_db) + engine._vector_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 1, + "content": "A", + "score": 0.9, + "search_type": "vector", + "metadata": {"filename": "a.md", "tags": []}, + } + ] + ) engine._filename_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._metadata_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._keyword_search = Mock(return_value=[]) # type: ignore[method-assign] # mock - engine._calculate_combined_score = Mock(side_effect=lambda c, t: c.get('score', 0.0)) # type: ignore[method-assign] # mock + engine._calculate_combined_score = Mock( # type: ignore[method-assign] # mock + side_effect=lambda c, t: c.get("score", 0.0) + ) diversity_mock = Mock(side_effect=lambda r, c: r) engine._apply_diversity_penalties = diversity_mock # type: ignore[method-assign] # mock - engine.search([0.9, 0.1, 0.0, 0.0], 'test', count=3) + engine.search([0.9, 0.1, 0.0, 0.0], "test", count=3) diversity_mock.assert_called_once() - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_search_sets_score_field(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_search_sets_score_field( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """search() ensures every result has a 'score' field.""" mock_np.array.return_value.reshape.return_value = [[0.9, 0.1, 0.0, 0.0]] - engine = SearchEngine(backend='sqlite', index_path=full_db) - engine._vector_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'content': 'A', 'score': 0.9, 'search_type': 'vector', - 'metadata': {'filename': 'a.md', 'tags': []}} - ]) + engine = SearchEngine(backend="sqlite", index_path=full_db) + engine._vector_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 1, + "content": "A", + "score": 0.9, + "search_type": "vector", + "metadata": {"filename": "a.md", "tags": []}, + } + ] + ) engine._filename_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._metadata_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._keyword_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._calculate_combined_score = Mock(return_value=0.85) # type: ignore[method-assign] # mock engine._apply_diversity_penalties = Mock(side_effect=lambda r, c: r) # type: ignore[method-assign] # mock - results = engine.search([0.9, 0.1, 0.0, 0.0], 'test', count=3) + results = engine.search([0.9, 0.1, 0.0, 0.0], "test", count=3) for r in results: - assert 'score' in r + assert "score" in r - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_search_similarity_threshold_filters(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_search_similarity_threshold_filters( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """search() filters by similarity_threshold when > 0.""" mock_np.array.return_value.reshape.return_value = [[0.9, 0.1, 0.0, 0.0]] - engine = SearchEngine(backend='sqlite', index_path=full_db) - engine._vector_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 1, 'content': 'A', 'score': 0.95, 'search_type': 'vector', - 'metadata': {'filename': 'a.md', 'tags': []}}, - {'id': 2, 'content': 'B', 'score': 0.1, 'search_type': 'vector', - 'metadata': {'filename': 'b.md', 'tags': []}}, - ]) + engine = SearchEngine(backend="sqlite", index_path=full_db) + engine._vector_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 1, + "content": "A", + "score": 0.95, + "search_type": "vector", + "metadata": {"filename": "a.md", "tags": []}, + }, + { + "id": 2, + "content": "B", + "score": 0.1, + "search_type": "vector", + "metadata": {"filename": "b.md", "tags": []}, + }, + ] + ) engine._filename_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._metadata_search = Mock(return_value=[]) # type: ignore[method-assign] # mock engine._keyword_search = Mock(return_value=[]) # type: ignore[method-assign] # mock - engine._calculate_combined_score = Mock(side_effect=lambda c, t: c.get('score', 0.0)) # type: ignore[method-assign] # mock + engine._calculate_combined_score = Mock( # type: ignore[method-assign] # mock + side_effect=lambda c, t: c.get("score", 0.0) + ) engine._apply_diversity_penalties = Mock(side_effect=lambda r, c: r) # type: ignore[method-assign] # mock - results = engine.search([0.9, 0.1, 0.0, 0.0], 'test', count=5, similarity_threshold=0.5) + results = engine.search( + [0.9, 0.1, 0.0, 0.0], "test", count=5, similarity_threshold=0.5 + ) # id=2 has vector_distance = 1 - 0.1 = 0.9, threshold*1.5 = 0.75 so 0.9 > 0.75 => filtered - assert all(r['id'] != 2 for r in results) + assert all(r["id"] != 2 for r in results) # --------------------------------------------------------------------------- # TestVectorSearch: _vector_search() # --------------------------------------------------------------------------- + class TestVectorSearch: """Tests for _vector_search().""" - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_returns_sorted_by_score(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_returns_sorted_by_score( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """Results are sorted by similarity descending.""" # Set up mock so frombuffer returns mock arrays mock_array = Mock() mock_array.reshape.return_value = [[0.0]] mock_np.frombuffer.return_value = mock_array - mock_np.float32 = 'float32' + mock_np.float32 = "float32" # Return decreasing similarities mock_cosine.side_effect = [[[0.5]], [[0.9]], [[0.3]], [[0.7]], [[0.4]], [[0.6]]] - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) results = engine._vector_search([[0.9, 0.1, 0.0, 0.0]], count=6) - scores = [r['score'] for r in results] + scores = [r["score"] for r in results] assert scores == sorted(scores, reverse=True) - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_limits_count(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_limits_count( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """Returns at most `count` results.""" mock_array = Mock() mock_array.reshape.return_value = [[0.0]] mock_np.frombuffer.return_value = mock_array - mock_np.float32 = 'float32' + mock_np.float32 = "float32" mock_cosine.return_value = [[0.8]] - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) results = engine._vector_search([[0.9, 0.1, 0.0, 0.0]], count=2) assert len(results) <= 2 - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_metadata_parsed_correctly(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_metadata_parsed_correctly( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """Metadata (tags, section, filename) is correctly parsed.""" mock_array = Mock() mock_array.reshape.return_value = [[0.0]] mock_np.frombuffer.return_value = mock_array - mock_np.float32 = 'float32' + mock_np.float32 = "float32" mock_cosine.return_value = [[0.95]] - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) results = engine._vector_search([[0.9, 0.1, 0.0, 0.0]], count=1) assert len(results) == 1 - md = results[0]['metadata'] - assert 'filename' in md - assert 'section' in md - assert isinstance(md['tags'], list) - - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_skips_empty_embeddings(self, mock_cosine: MagicMock, mock_np: MagicMock, db_no_embeddings: str) -> None: + md = results[0]["metadata"] + assert "filename" in md + assert "section" in md + assert isinstance(md["tags"], list) + + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_skips_empty_embeddings( + self, mock_cosine: MagicMock, mock_np: MagicMock, db_no_embeddings: str + ) -> None: """Chunks with NULL embeddings are skipped.""" mock_np.frombuffer.return_value = Mock(reshape=Mock(return_value=[[0.0]])) - mock_np.float32 = 'float32' + mock_np.float32 = "float32" mock_cosine.return_value = [[0.8]] - engine = SearchEngine(backend='sqlite', index_path=db_no_embeddings) + engine = SearchEngine(backend="sqlite", index_path=db_no_embeddings) results = engine._vector_search([[0.1, 0.1, 0.1, 0.1]], count=10) assert results == [] - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_handles_embedding_processing_error(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_handles_embedding_processing_error( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """Gracefully continues when one embedding fails to process.""" call_count = [0] + def frombuffer_side_effect(*args: Any, **kwargs: Any) -> Mock: call_count[0] += 1 if call_count[0] == 2: @@ -1080,115 +1469,131 @@ def frombuffer_side_effect(*args: Any, **kwargs: Any) -> Mock: return m mock_np.frombuffer.side_effect = frombuffer_side_effect - mock_np.float32 = 'float32' + mock_np.float32 = "float32" mock_cosine.return_value = [[0.8]] - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) results = engine._vector_search([[0.9, 0.1, 0.0, 0.0]], count=10) # Should still return results from non-corrupt chunks assert len(results) >= 1 - @patch('signalwire.search.search_engine.np', None) - @patch('signalwire.search.search_engine.cosine_similarity', None) + @patch("signalwire.search.search_engine.np", None) + @patch("signalwire.search.search_engine.cosine_similarity", None) def test_no_numpy_returns_empty(self, full_db: str) -> None: """Returns [] when numpy is not available.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) assert engine._vector_search([[0.1]], count=5) == [] - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_db_error_returns_empty(self, mock_cosine: MagicMock, mock_np: MagicMock) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_db_error_returns_empty( + self, mock_cosine: MagicMock, mock_np: MagicMock + ) -> None: """Returns [] when the database cannot be opened.""" - engine = SearchEngine(backend='sqlite', index_path='/no/such/file.db') + engine = SearchEngine(backend="sqlite", index_path="/no/such/file.db") assert engine._vector_search([[0.1]], count=5) == [] - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_search_type_is_vector(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_search_type_is_vector( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """All results have search_type == 'vector'.""" mock_array = Mock() mock_array.reshape.return_value = [[0.0]] mock_np.frombuffer.return_value = mock_array - mock_np.float32 = 'float32' + mock_np.float32 = "float32" mock_cosine.return_value = [[0.7]] - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) results = engine._vector_search([[0.1, 0.1, 0.1, 0.1]], count=10) for r in results: - assert r['search_type'] == 'vector' + assert r["search_type"] == "vector" # --------------------------------------------------------------------------- # TestKeywordSearch: _keyword_search() # --------------------------------------------------------------------------- + class TestKeywordSearch: """Tests for _keyword_search() with FTS5.""" def test_finds_matching_content(self, full_db: str) -> None: """Finds chunks whose content matches keywords.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._keyword_search('python', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._keyword_search("python", count=10) assert len(results) >= 1 - assert all(r['search_type'] == 'keyword' for r in results) + assert all(r["search_type"] == "keyword" for r in results) def test_multiple_term_query(self, full_db: str) -> None: """Handles multi-word queries.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._keyword_search('python tutorial', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._keyword_search("python tutorial", count=10) assert len(results) >= 1 def test_no_results_triggers_fallback(self, full_db: str) -> None: """Falls back to LIKE search when FTS returns nothing.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - engine._fallback_search = Mock(return_value=[ # type: ignore[method-assign] # mock - {'id': 99, 'content': 'fallback', 'score': 0.1, 'search_type': 'fallback', - 'metadata': {}} - ]) - results = engine._keyword_search('zzzznonexistent', count=5) + engine = SearchEngine(backend="sqlite", index_path=full_db) + engine._fallback_search = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "id": 99, + "content": "fallback", + "score": 0.1, + "search_type": "fallback", + "metadata": {}, + } + ] + ) + results = engine._keyword_search("zzzznonexistent", count=5) engine._fallback_search.assert_called_once() + # The fallback's rows are what _keyword_search hands back. + assert [r["search_type"] for r in results] == ["fallback"] def test_scores_are_positive_floats(self, full_db: str) -> None: """Scores are positive floats derived from FTS rank.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._keyword_search('python', count=5) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._keyword_search("python", count=5) for r in results: - assert isinstance(r['score'], float) - assert r['score'] > 0 + assert isinstance(r["score"], float) + assert r["score"] > 0 def test_respects_count_limit(self, full_db: str) -> None: """Returns at most `count` results.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._keyword_search('python', count=1) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._keyword_search("python", count=1) assert len(results) <= 1 def test_metadata_parsed(self, full_db: str) -> None: """Metadata (tags, section, filename) is parsed from JSON.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._keyword_search('python', count=1) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._keyword_search("python", count=1) if results: - assert 'filename' in results[0]['metadata'] - assert isinstance(results[0]['metadata']['tags'], list) + assert "filename" in results[0]["metadata"] + assert isinstance(results[0]["metadata"]["tags"], list) def test_fts_error_falls_back(self, db_no_fts: str) -> None: """Falls back to LIKE search when FTS table doesn't exist.""" - engine = SearchEngine(backend='sqlite', index_path=db_no_fts) - results = engine._keyword_search('python', count=5) + engine = SearchEngine(backend="sqlite", index_path=db_no_fts) + results = engine._keyword_search("python", count=5) # Should get fallback results (or empty) for r in results: - assert r['search_type'] == 'fallback' + assert r["search_type"] == "fallback" def test_original_query_passed_to_fallback(self, full_db: str) -> None: """original_query parameter is available.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) # A query that returns results shouldn't hit fallback - results = engine._keyword_search('python', count=5, original_query='python programming') + results = engine._keyword_search( + "python", count=5, original_query="python programming" + ) assert len(results) >= 1 def test_empty_query_string(self, full_db: str) -> None: """Handles empty string query gracefully.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._keyword_search('', count=5) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._keyword_search("", count=5) # May return empty or fallback results; should not crash assert isinstance(results, list) @@ -1197,71 +1602,72 @@ def test_empty_query_string(self, full_db: str) -> None: # TestMetadataSearch: _metadata_search() # --------------------------------------------------------------------------- + class TestMetadataSearch: """Tests for _metadata_search().""" def test_finds_by_tag(self, full_db: str) -> None: """Finds chunks that have matching tags.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._metadata_search('python', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._metadata_search("python", count=10) assert len(results) >= 1 # At least one should have 'python' in tags - found_tag = any('python' in r['metadata'].get('tags', []) for r in results) + found_tag = any("python" in r["metadata"].get("tags", []) for r in results) assert found_tag def test_finds_by_section(self, full_db: str) -> None: """Finds chunks with matching section names.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._metadata_search('decorators', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._metadata_search("decorators", count=10) assert len(results) >= 1 def test_finds_by_category_in_metadata(self, full_db: str) -> None: """Finds chunks with matching category metadata.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._metadata_search('tutorial', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._metadata_search("tutorial", count=10) assert len(results) >= 1 def test_finds_by_product_in_metadata(self, full_db: str) -> None: """Finds chunks with matching product metadata.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._metadata_search('sdk', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._metadata_search("sdk", count=10) assert len(results) >= 1 def test_search_type_is_metadata(self, full_db: str) -> None: """All results have search_type == 'metadata'.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._metadata_search('python', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._metadata_search("python", count=10) for r in results: - assert r['search_type'] == 'metadata' + assert r["search_type"] == "metadata" def test_metadata_text_column_used(self, full_db_with_metadata_text: str) -> None: """Uses metadata_text column for searching when it exists.""" - engine = SearchEngine(backend='sqlite', index_path=full_db_with_metadata_text) - results = engine._metadata_search('python', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db_with_metadata_text) + results = engine._metadata_search("python", count=10) assert len(results) >= 1 def test_multi_term_scoring(self, full_db: str) -> None: """Multi-term queries produce higher scores for multi-field matches.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._metadata_search('code examples', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._metadata_search("code examples", count=10) assert len(results) >= 1 def test_respects_count_limit(self, full_db: str) -> None: """Returns at most `count` results.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._metadata_search('python', count=1) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._metadata_search("python", count=1) assert len(results) <= 1 def test_db_error_returns_empty(self) -> None: """Returns [] on database error.""" - engine = SearchEngine(backend='sqlite', index_path='/nonexistent/path.db') - results = engine._metadata_search('python', count=5) + engine = SearchEngine(backend="sqlite", index_path="/nonexistent/path.db") + results = engine._metadata_search("python", count=5) assert results == [] def test_empty_query(self, full_db: str) -> None: """Handles empty query gracefully.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._metadata_search('', count=5) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._metadata_search("", count=5) assert isinstance(results, list) @@ -1269,138 +1675,140 @@ def test_empty_query(self, full_db: str) -> None: # TestFilenameSearch: _filename_search() # --------------------------------------------------------------------------- + class TestFilenameSearch: """Tests for _filename_search().""" def test_exact_filename_match(self, full_db: str) -> None: """Finds chunks by exact filename match.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._filename_search('python_tutorial', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._filename_search("python_tutorial", count=10) assert len(results) >= 1 - assert any('python_tutorial' in r['metadata']['filename'] for r in results) + assert any("python_tutorial" in r["metadata"]["filename"] for r in results) def test_partial_filename_match(self, full_db: str) -> None: """Finds chunks with partial filename matches.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._filename_search('tutorial', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._filename_search("tutorial", count=10) assert len(results) >= 1 def test_search_type_is_filename(self, full_db: str) -> None: """All results have search_type == 'filename'.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._filename_search('python', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._filename_search("python", count=10) for r in results: - assert r['search_type'] == 'filename' + assert r["search_type"] == "filename" def test_basename_match_scores_higher(self, full_db: str) -> None: """Basename matches score higher than path-only matches.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._filename_search('python_tutorial.md', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._filename_search("python_tutorial.md", count=10) if results: # Exact basename match should have high score - assert results[0]['score'] >= 2.0 + assert results[0]["score"] >= 2.0 def test_multi_term_filename_search(self, full_db: str) -> None: """Multi-term queries match against filename.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._filename_search('rest api example', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._filename_search("rest api example", count=10) assert len(results) >= 1 def test_respects_count(self, full_db: str) -> None: """Returns at most `count` results.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._filename_search('python', count=1) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._filename_search("python", count=1) assert len(results) <= 1 def test_no_match_returns_empty(self, full_db: str) -> None: """Returns empty list when no filenames match.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._filename_search('zzz_nonexistent_file', count=5) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._filename_search("zzz_nonexistent_file", count=5) assert results == [] def test_db_error_returns_empty(self) -> None: """Returns [] on database error.""" - engine = SearchEngine(backend='sqlite', index_path='/nonexistent/path.db') - results = engine._filename_search('test', count=5) + engine = SearchEngine(backend="sqlite", index_path="/nonexistent/path.db") + results = engine._filename_search("test", count=5) assert results == [] def test_match_coverage_present(self, full_db: str) -> None: """Results contain match_coverage metadata.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._filename_search('python', count=5) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._filename_search("python", count=5) for r in results: - assert 'match_coverage' in r + assert "match_coverage" in r # --------------------------------------------------------------------------- # TestFallbackSearch: _fallback_search() # --------------------------------------------------------------------------- + class TestFallbackSearch: """Tests for _fallback_search() LIKE-based search.""" def test_finds_by_processed_content(self, full_db: str) -> None: """Finds chunks matching processed_content via LIKE.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._fallback_search('python', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._fallback_search("python", count=10) assert len(results) >= 1 def test_finds_by_original_content(self, full_db: str) -> None: """Finds chunks matching original content via LIKE.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._fallback_search('decorators', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._fallback_search("decorators", count=10) assert len(results) >= 1 def test_search_type_is_fallback(self, full_db: str) -> None: """All results have search_type == 'fallback'.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._fallback_search('python', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._fallback_search("python", count=10) for r in results: - assert r['search_type'] == 'fallback' + assert r["search_type"] == "fallback" def test_scoring_based_on_word_matches(self, full_db: str) -> None: """Score reflects how many query terms appear in content.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._fallback_search('python programming tutorial', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._fallback_search("python programming tutorial", count=10) if results: # The chunk with all 3 words should score highest - assert results[0]['score'] > 0 + assert results[0]["score"] > 0 def test_limits_to_five_terms(self, full_db: str) -> None: """Only uses first 5 search terms (no error for more).""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._fallback_search('a b c d e f g h', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._fallback_search("a b c d e f g h", count=10) assert isinstance(results, list) def test_empty_query_returns_empty(self, full_db: str) -> None: """Returns [] for an empty query string.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._fallback_search('', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._fallback_search("", count=10) assert results == [] def test_respects_count_limit(self, full_db: str) -> None: """Returns at most `count` results.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._fallback_search('python', count=1) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._fallback_search("python", count=1) assert len(results) <= 1 def test_db_error_returns_empty(self) -> None: """Returns [] on database error.""" - engine = SearchEngine(backend='sqlite', index_path='/nonexistent/path.db') - results = engine._fallback_search('test', count=5) + engine = SearchEngine(backend="sqlite", index_path="/nonexistent/path.db") + results = engine._fallback_search("test", count=5) assert results == [] def test_sorted_by_score_descending(self, full_db: str) -> None: """Results are sorted by score descending.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._fallback_search('python', count=10) - scores = [r['score'] for r in results] + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._fallback_search("python", count=10) + scores = [r["score"] for r in results] assert scores == sorted(scores, reverse=True) def test_no_match_returns_empty(self, full_db: str) -> None: """Returns [] when no content matches.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._fallback_search('xyznonexistent', count=10) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._fallback_search("xyznonexistent", count=10) assert results == [] @@ -1410,6 +1818,7 @@ def test_no_match_returns_empty(self, full_db: str) -> None: # _apply_match_type_diversity # --------------------------------------------------------------------------- + class TestResultProcessing: """Tests for result ranking, merging, and diversity methods.""" @@ -1422,52 +1831,54 @@ def _make_engine(self, tmp_path: Path) -> SearchEngine: c.execute("INSERT INTO config (key,value) VALUES ('embedding_dimensions','4')") conn.commit() conn.close() - return SearchEngine(backend='sqlite', index_path=db_path) + return SearchEngine(backend="sqlite", index_path=db_path) # -- _merge_results -- def test_merge_no_overlap(self, tmp_path: Path) -> None: """Merging disjoint sets yields union.""" engine = self._make_engine(tmp_path) - v = [{'id': 1, 'content': 'A', 'score': 0.9, 'metadata': {}}] - k = [{'id': 2, 'content': 'B', 'score': 0.8, 'metadata': {}}] + v = [{"id": 1, "content": "A", "score": 0.9, "metadata": {}}] + k = [{"id": 2, "content": "B", "score": 0.8, "metadata": {}}] merged = engine._merge_results(v, k) assert len(merged) == 2 def test_merge_overlap_combines_scores(self, tmp_path: Path) -> None: """Overlapping IDs get combined score.""" engine = self._make_engine(tmp_path) - v = [{'id': 1, 'content': 'A', 'score': 0.8, 'metadata': {}}] - k = [{'id': 1, 'content': 'A', 'score': 0.6, 'metadata': {}}] + v = [{"id": 1, "content": "A", "score": 0.8, "metadata": {}}] + k = [{"id": 1, "content": "A", "score": 0.6, "metadata": {}}] merged = engine._merge_results(v, k) assert len(merged) == 1 # 0.8 * 0.7 + 0.6 * 0.3 = 0.74 - assert abs(merged[0]['score'] - 0.74) < 0.01 + assert abs(merged[0]["score"] - 0.74) < 0.01 def test_merge_custom_weights(self, tmp_path: Path) -> None: """Custom weights are applied.""" engine = self._make_engine(tmp_path) - v = [{'id': 1, 'content': 'A', 'score': 1.0, 'metadata': {}}] - k = [{'id': 1, 'content': 'A', 'score': 1.0, 'metadata': {}}] + v = [{"id": 1, "content": "A", "score": 1.0, "metadata": {}}] + k = [{"id": 1, "content": "A", "score": 1.0, "metadata": {}}] merged = engine._merge_results(v, k, vector_weight=0.5, keyword_weight=0.5) - assert abs(merged[0]['score'] - 1.0) < 0.01 + assert abs(merged[0]["score"] - 1.0) < 0.01 def test_merge_sorted_by_score(self, tmp_path: Path) -> None: """Merged results are sorted by combined score descending.""" engine = self._make_engine(tmp_path) - v = [{'id': 1, 'content': 'A', 'score': 0.3, 'metadata': {}}, - {'id': 2, 'content': 'B', 'score': 0.9, 'metadata': {}}] + v = [ + {"id": 1, "content": "A", "score": 0.3, "metadata": {}}, + {"id": 2, "content": "B", "score": 0.9, "metadata": {}}, + ] k: list[dict[str, Any]] = [] merged = engine._merge_results(v, k) - assert merged[0]['id'] == 2 + assert merged[0]["id"] == 2 def test_merge_adds_search_scores(self, tmp_path: Path) -> None: """Merged results contain search_scores debug info.""" engine = self._make_engine(tmp_path) - v = [{'id': 1, 'content': 'A', 'score': 0.8, 'metadata': {}}] - k = [{'id': 1, 'content': 'A', 'score': 0.6, 'metadata': {}}] + v = [{"id": 1, "content": "A", "score": 0.8, "metadata": {}}] + k = [{"id": 1, "content": "A", "score": 0.6, "metadata": {}}] merged = engine._merge_results(v, k) - assert 'search_scores' in merged[0]['metadata'] + assert "search_scores" in merged[0]["metadata"] def test_merge_empty_inputs(self, tmp_path: Path) -> None: """Merging two empty lists returns empty.""" @@ -1481,49 +1892,71 @@ def test_boost_exact_phrase_match(self, tmp_path: Path) -> None: """Exact phrase match doubles score.""" engine = self._make_engine(tmp_path) results = [ - {'id': 1, 'content': 'python tutorial for beginners', 'score': 0.5, - 'final_score': 0.5, 'metadata': {'filename': 'a.md'}}, + { + "id": 1, + "content": "python tutorial for beginners", + "score": 0.5, + "final_score": 0.5, + "metadata": {"filename": "a.md"}, + }, ] - boosted = engine._boost_exact_matches(results, 'python tutorial') - assert boosted[0]['final_score'] == 0.5 * 2.0 + boosted = engine._boost_exact_matches(results, "python tutorial") + assert boosted[0]["final_score"] == 0.5 * 2.0 def test_boost_no_match_unchanged(self, tmp_path: Path) -> None: """No boost when query not found in content.""" engine = self._make_engine(tmp_path) results = [ - {'id': 1, 'content': 'javascript async await', 'score': 0.5, - 'final_score': 0.5, 'metadata': {'filename': 'a.md'}}, + { + "id": 1, + "content": "javascript async await", + "score": 0.5, + "final_score": 0.5, + "metadata": {"filename": "a.md"}, + }, ] - boosted = engine._boost_exact_matches(results, 'python tutorial') - assert boosted[0]['final_score'] == 0.5 + boosted = engine._boost_exact_matches(results, "python tutorial") + assert boosted[0]["final_score"] == 0.5 def test_boost_example_filename(self, tmp_path: Path) -> None: """Boosts results with 'example' in filename for code queries.""" engine = self._make_engine(tmp_path) results = [ - {'id': 1, 'content': 'some code', 'score': 0.5, - 'final_score': 0.5, 'metadata': {'filename': 'code_example.py'}}, + { + "id": 1, + "content": "some code", + "score": 0.5, + "final_score": 0.5, + "metadata": {"filename": "code_example.py"}, + }, ] - boosted = engine._boost_exact_matches(results, 'code example') + boosted = engine._boost_exact_matches(results, "code example") # 'code' in query, 'example' in filename => 1.5x boost - assert boosted[0]['final_score'] > 0.5 + assert boosted[0]["final_score"] > 0.5 def test_boost_getting_started(self, tmp_path: Path) -> None: """Boosts 'getting started' queries with 'start' in content.""" engine = self._make_engine(tmp_path) results = [ - {'id': 1, 'content': 'how to start building agents', 'score': 0.5, - 'final_score': 0.5, 'metadata': {'filename': 'guide.md'}}, + { + "id": 1, + "content": "how to start building agents", + "score": 0.5, + "final_score": 0.5, + "metadata": {"filename": "guide.md"}, + }, ] - boosted = engine._boost_exact_matches(results, 'getting started') - assert boosted[0]['final_score'] > 0.5 + boosted = engine._boost_exact_matches(results, "getting started") + assert boosted[0]["final_score"] > 0.5 def test_boost_empty_query_no_change(self, tmp_path: Path) -> None: """Empty original_query returns results unchanged.""" engine = self._make_engine(tmp_path) - results = [{'id': 1, 'content': 'x', 'score': 0.5, 'metadata': {'filename': 'a.md'}}] - boosted = engine._boost_exact_matches(results, '') - assert boosted[0]['score'] == 0.5 + results = [ + {"id": 1, "content": "x", "score": 0.5, "metadata": {"filename": "a.md"}} + ] + boosted = engine._boost_exact_matches(results, "") + assert boosted[0]["score"] == 0.5 # -- _calculate_combined_score -- @@ -1531,10 +1964,10 @@ def test_combined_score_vector_only(self, tmp_path: Path) -> None: """Candidate with vector score only uses it as base.""" engine = self._make_engine(tmp_path) candidate = { - 'vector_score': 0.9, - 'sources': {'vector': True}, - 'source_scores': {'vector': 0.9}, - 'metadata': {'tags': []} + "vector_score": 0.9, + "sources": {"vector": True}, + "source_scores": {"vector": 0.9}, + "metadata": {"tags": []}, } score = engine._calculate_combined_score(candidate, 0.0) assert abs(score - 0.9) < 0.01 @@ -1543,10 +1976,10 @@ def test_combined_score_with_keyword_boost(self, tmp_path: Path) -> None: """Keyword confirmation boosts vector score.""" engine = self._make_engine(tmp_path) candidate = { - 'vector_score': 0.8, - 'sources': {'vector': True, 'keyword': True}, - 'source_scores': {'vector': 0.8, 'keyword': 0.5}, - 'metadata': {'tags': []} + "vector_score": 0.8, + "sources": {"vector": True, "keyword": True}, + "source_scores": {"vector": 0.8, "keyword": 0.5}, + "metadata": {"tags": []}, } score = engine._calculate_combined_score(candidate, 0.0) assert score > 0.8 # Boosted @@ -1555,10 +1988,10 @@ def test_combined_score_multi_source_boost(self, tmp_path: Path) -> None: """Multiple metadata source types give extra boost.""" engine = self._make_engine(tmp_path) candidate = { - 'vector_score': 0.8, - 'sources': {'vector': True, 'keyword': True, 'filename': True}, - 'source_scores': {'vector': 0.8, 'keyword': 0.5, 'filename': 0.6}, - 'metadata': {'tags': []} + "vector_score": 0.8, + "sources": {"vector": True, "keyword": True, "filename": True}, + "source_scores": {"vector": 0.8, "keyword": 0.5, "filename": 0.6}, + "metadata": {"tags": []}, } score = engine._calculate_combined_score(candidate, 0.0) assert score > 0.8 @@ -1573,9 +2006,9 @@ def test_combined_score_no_vector(self, tmp_path: Path) -> None: """ engine = self._make_engine(tmp_path) candidate = { - 'sources': {'keyword': True}, - 'source_scores': {'keyword': 1.0}, - 'metadata': {'tags': []} + "sources": {"keyword": True}, + "source_scores": {"keyword": 1.0}, + "metadata": {"tags": []}, } score = engine._calculate_combined_score(candidate, 0.0) assert abs(score - 1.0) < 0.01 @@ -1584,10 +2017,10 @@ def test_combined_score_code_tag_boost(self, tmp_path: Path) -> None: """Code tag with metadata source gets boosted.""" engine = self._make_engine(tmp_path) candidate = { - 'vector_score': 0.7, - 'sources': {'vector': True, 'metadata': True}, - 'source_scores': {'vector': 0.7, 'metadata': 0.5}, - 'metadata': {'tags': ['code']} + "vector_score": 0.7, + "sources": {"vector": True, "metadata": True}, + "source_scores": {"vector": 0.7, "metadata": 0.5}, + "metadata": {"tags": ["code"]}, } score = engine._calculate_combined_score(candidate, 0.0) # Should be > 0.7 due to keyword boost + code boost @@ -1604,9 +2037,9 @@ def test_combined_score_no_vector_code_tag(self, tmp_path: Path) -> None: """ engine = self._make_engine(tmp_path) candidate = { - 'sources': {'metadata': True}, - 'source_scores': {'metadata': 1.0}, - 'metadata': {'tags': ['code']} + "sources": {"metadata": True}, + "source_scores": {"metadata": 1.0}, + "metadata": {"tags": ["code"]}, } score = engine._calculate_combined_score(candidate, 0.0) assert abs(score - 1.0) < 0.01 @@ -1622,54 +2055,88 @@ def test_diversity_first_occurrence_no_penalty(self, tmp_path: Path) -> None: """First chunk from a file has no penalty (1.0).""" engine = self._make_engine(tmp_path) results = [ - {'id': 1, 'content': 'A', 'score': 0.9, 'final_score': 0.9, - 'metadata': {'filename': 'file1.md'}}, + { + "id": 1, + "content": "A", + "score": 0.9, + "final_score": 0.9, + "metadata": {"filename": "file1.md"}, + }, ] penalized = engine._apply_diversity_penalties(results, 3) - assert penalized[0]['diversity_penalty'] == 1.0 + assert penalized[0]["diversity_penalty"] == 1.0 def test_diversity_second_occurrence_penalized(self, tmp_path: Path) -> None: """Second chunk from the same file gets 0.85 penalty.""" engine = self._make_engine(tmp_path) results = [ - {'id': 1, 'content': 'A', 'score': 0.9, 'final_score': 0.9, - 'metadata': {'filename': 'file1.md'}}, - {'id': 2, 'content': 'B', 'score': 0.8, 'final_score': 0.8, - 'metadata': {'filename': 'file1.md'}}, + { + "id": 1, + "content": "A", + "score": 0.9, + "final_score": 0.9, + "metadata": {"filename": "file1.md"}, + }, + { + "id": 2, + "content": "B", + "score": 0.8, + "final_score": 0.8, + "metadata": {"filename": "file1.md"}, + }, ] penalized = engine._apply_diversity_penalties(results, 3) - same_file = [r for r in penalized if r['metadata']['filename'] == 'file1.md'] - penalties = sorted([r['diversity_penalty'] for r in same_file], reverse=True) + same_file = [r for r in penalized if r["metadata"]["filename"] == "file1.md"] + penalties = sorted([r["diversity_penalty"] for r in same_file], reverse=True) assert penalties == [1.0, 0.85] def test_diversity_resorts_by_penalized_score(self, tmp_path: Path) -> None: """Results are re-sorted after penalties.""" engine = self._make_engine(tmp_path) results = [ - {'id': 1, 'content': 'A', 'score': 0.9, 'final_score': 0.9, - 'metadata': {'filename': 'same.md'}}, - {'id': 2, 'content': 'B', 'score': 0.89, 'final_score': 0.89, - 'metadata': {'filename': 'same.md'}}, - {'id': 3, 'content': 'C', 'score': 0.7, 'final_score': 0.7, - 'metadata': {'filename': 'other.md'}}, + { + "id": 1, + "content": "A", + "score": 0.9, + "final_score": 0.9, + "metadata": {"filename": "same.md"}, + }, + { + "id": 2, + "content": "B", + "score": 0.89, + "final_score": 0.89, + "metadata": {"filename": "same.md"}, + }, + { + "id": 3, + "content": "C", + "score": 0.7, + "final_score": 0.7, + "metadata": {"filename": "other.md"}, + }, ] penalized = engine._apply_diversity_penalties(results, 3) - scores = [r['final_score'] for r in penalized] + scores = [r["final_score"] for r in penalized] assert scores == sorted(scores, reverse=True) def test_diversity_heavy_penalty_for_4plus(self, tmp_path: Path) -> None: """4th and 5th chunks from same file get 50%/60% penalty.""" engine = self._make_engine(tmp_path) results = [ - {'id': i, 'content': f'C{i}', 'score': 1.0 - i * 0.01, - 'final_score': 1.0 - i * 0.01, - 'metadata': {'filename': 'same.md'}} + { + "id": i, + "content": f"C{i}", + "score": 1.0 - i * 0.01, + "final_score": 1.0 - i * 0.01, + "metadata": {"filename": "same.md"}, + } for i in range(5) ] penalized = engine._apply_diversity_penalties(results, 10) # 5th occurrence should have 0.4 penalty - fifth = [r for r in penalized if r['id'] == 4][0] - assert fifth['diversity_penalty'] == 0.4 + fifth = next(r for r in penalized if r["id"] == 4) + assert fifth["diversity_penalty"] == 0.4 # -- _apply_match_type_diversity -- @@ -1678,14 +2145,18 @@ def test_match_type_diversity_with_all_types(self, tmp_path: Path) -> None: engine = self._make_engine(tmp_path) results = [] for i in range(6): - r = {'id': i, 'content': f'C{i}', 'final_score': 1.0 - i * 0.05, - 'metadata': {'filename': f'f{i}.md'}} + r = { + "id": i, + "content": f"C{i}", + "final_score": 1.0 - i * 0.05, + "metadata": {"filename": f"f{i}.md"}, + } if i < 2: - r['sources'] = {'vector': True} + r["sources"] = {"vector": True} elif i < 4: - r['sources'] = {'keyword': True} + r["sources"] = {"keyword": True} else: - r['sources'] = {'vector': True, 'keyword': True} + r["sources"] = {"vector": True, "keyword": True} results.append(r) diversified = engine._apply_match_type_diversity(results, 3) assert len(diversified) == 3 @@ -1694,8 +2165,12 @@ def test_match_type_diversity_short_list_unchanged(self, tmp_path: Path) -> None """Lists shorter than target_count are returned as-is.""" engine = self._make_engine(tmp_path) results = [ - {'id': 1, 'final_score': 0.9, 'sources': {'vector': True}, - 'metadata': {'filename': 'a.md'}}, + { + "id": 1, + "final_score": 0.9, + "sources": {"vector": True}, + "metadata": {"filename": "a.md"}, + }, ] diversified = engine._apply_match_type_diversity(results, 5) assert len(diversified) == 1 @@ -1710,17 +2185,21 @@ def test_match_type_diversity_sorted_by_final_score(self, tmp_path: Path) -> Non engine = self._make_engine(tmp_path) results = [] for i in range(8): - r = {'id': i, 'content': f'C{i}', 'final_score': 0.5 + (i % 3) * 0.1, - 'metadata': {'filename': f'f{i}.md'}} + r = { + "id": i, + "content": f"C{i}", + "final_score": 0.5 + (i % 3) * 0.1, + "metadata": {"filename": f"f{i}.md"}, + } if i % 3 == 0: - r['sources'] = {'vector': True} + r["sources"] = {"vector": True} elif i % 3 == 1: - r['sources'] = {'keyword': True} + r["sources"] = {"keyword": True} else: - r['sources'] = {'vector': True, 'keyword': True} + r["sources"] = {"vector": True, "keyword": True} results.append(r) diversified = engine._apply_match_type_diversity(results, 4) - scores = [r['final_score'] for r in diversified] + scores = [r["final_score"] for r in diversified] assert scores == sorted(scores, reverse=True) @@ -1728,196 +2207,208 @@ def test_match_type_diversity_sorted_by_final_score(self, tmp_path: Path) -> Non # TestEdgeCases: Assorted edge cases and error handling # --------------------------------------------------------------------------- + class TestEdgeCases: """Edge cases: empty results, unavailable dependencies, special characters.""" def test_empty_db_search_returns_empty(self, empty_db: str) -> None: """search() on empty DB returns empty list.""" - engine = SearchEngine(backend='sqlite', index_path=empty_db) + engine = SearchEngine(backend="sqlite", index_path=empty_db) engine._keyword_search_only = Mock(return_value=[]) # type: ignore[method-assign] # mock - results = engine.search([0.1], 'test', count=3) + results = engine.search([0.1], "test", count=3) assert results == [] def test_empty_db_keyword_search(self, empty_db: str) -> None: """_keyword_search on empty DB returns [].""" - engine = SearchEngine(backend='sqlite', index_path=empty_db) - results = engine._keyword_search('test', count=5) + engine = SearchEngine(backend="sqlite", index_path=empty_db) + results = engine._keyword_search("test", count=5) assert results == [] def test_empty_db_fallback_search(self, empty_db: str) -> None: """_fallback_search on empty DB returns [].""" - engine = SearchEngine(backend='sqlite', index_path=empty_db) - results = engine._fallback_search('test', count=5) + engine = SearchEngine(backend="sqlite", index_path=empty_db) + results = engine._fallback_search("test", count=5) assert results == [] def test_empty_db_filename_search(self, empty_db: str) -> None: """_filename_search on empty DB returns [].""" - engine = SearchEngine(backend='sqlite', index_path=empty_db) - results = engine._filename_search('test', count=5) + engine = SearchEngine(backend="sqlite", index_path=empty_db) + results = engine._filename_search("test", count=5) assert results == [] def test_empty_db_metadata_search(self, empty_db: str) -> None: """_metadata_search on empty DB returns [].""" - engine = SearchEngine(backend='sqlite', index_path=empty_db) - results = engine._metadata_search('test', count=5) + engine = SearchEngine(backend="sqlite", index_path=empty_db) + results = engine._metadata_search("test", count=5) assert results == [] - @patch('signalwire.search.search_engine.np', None) - @patch('signalwire.search.search_engine.cosine_similarity', None) + @patch("signalwire.search.search_engine.np", None) + @patch("signalwire.search.search_engine.cosine_similarity", None) def test_numpy_unavailable_keyword_search_only(self, full_db: str) -> None: """When numpy is None, search() uses keyword-only path.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine.search([0.1], 'python', count=3) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine.search([0.1], "python", count=3) # Should get keyword results (or empty) but not crash assert isinstance(results, list) def test_special_characters_in_keyword_query(self, full_db: str) -> None: """Special characters in query don't crash keyword search.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) results = engine._keyword_search('test* OR "hello"', count=5) assert isinstance(results, list) def test_special_characters_in_fallback_query(self, full_db: str) -> None: """Special characters in query don't crash fallback search.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) results = engine._fallback_search("it's a test% with_underscores", count=5) assert isinstance(results, list) def test_special_characters_in_filename_query(self, full_db: str) -> None: """Special characters in query don't crash filename search.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) results = engine._filename_search("test/path with spaces", count=5) assert isinstance(results, list) def test_special_characters_in_metadata_query(self, full_db: str) -> None: """Special characters in query don't crash metadata search.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) results = engine._metadata_search('test "with" quotes', count=5) assert isinstance(results, list) def test_fts5_unavailable_falls_back(self, db_no_fts: str) -> None: """When FTS5 table is missing, _keyword_search falls back to LIKE.""" - engine = SearchEngine(backend='sqlite', index_path=db_no_fts) - results = engine._keyword_search('python', count=5) + engine = SearchEngine(backend="sqlite", index_path=db_no_fts) + results = engine._keyword_search("python", count=5) # Fallback results should work if results: - assert results[0]['search_type'] == 'fallback' + assert results[0]["search_type"] == "fallback" def test_init_invalid_backend_raises(self) -> None: """Invalid backend name raises ValueError.""" with pytest.raises(ValueError, match="Invalid backend"): - SearchEngine(backend='invalid', index_path='test.db') + SearchEngine(backend="invalid", index_path="test.db") def test_init_sqlite_no_path_raises(self) -> None: """sqlite backend without index_path raises ValueError.""" with pytest.raises(ValueError, match="index_path is required"): - SearchEngine(backend='sqlite') + SearchEngine(backend="sqlite") def test_init_pgvector_no_connection_raises(self) -> None: """pgvector backend without connection_string raises ValueError.""" - with pytest.raises(ValueError, match="connection_string and collection_name are required"): - SearchEngine(backend='pgvector') + with pytest.raises( + ValueError, match="connection_string and collection_name are required" + ): + SearchEngine(backend="pgvector") def test_keyword_search_only_no_tags(self, full_db: str) -> None: """_keyword_search_only without tags returns keyword results.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._keyword_search_only('python', count=5) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._keyword_search_only("python", count=5) assert isinstance(results, list) def test_keyword_search_only_with_tags(self, full_db: str) -> None: """_keyword_search_only with tags filters results.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - results = engine._keyword_search_only('python', count=5, tags=['python']) + engine = SearchEngine(backend="sqlite", index_path=full_db) + results = engine._keyword_search_only("python", count=5, tags=["python"]) for r in results: - assert 'python' in r['metadata'].get('tags', []) + assert "python" in r["metadata"].get("tags", []) def test_escape_fts_query_empty(self, full_db: str) -> None: """Empty string is escaped to empty string.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - assert engine._escape_fts_query('') == '' + engine = SearchEngine(backend="sqlite", index_path=full_db) + assert engine._escape_fts_query("") == "" def test_escape_fts_query_single_term(self, full_db: str) -> None: """Single term is quoted.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - assert engine._escape_fts_query('hello') == '"hello"' + engine = SearchEngine(backend="sqlite", index_path=full_db) + assert engine._escape_fts_query("hello") == '"hello"' def test_escape_fts_query_strips_quotes(self, full_db: str) -> None: """Existing double quotes are stripped then re-quoted.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) assert engine._escape_fts_query('"hello" "world"') == '"hello" "world"' def test_get_stats_empty_db(self, empty_db: str) -> None: """get_stats on empty DB returns zero counts.""" - engine = SearchEngine(backend='sqlite', index_path=empty_db) + engine = SearchEngine(backend="sqlite", index_path=empty_db) stats = engine.get_stats() - assert stats['total_chunks'] == 0 - assert stats['total_files'] == 0 + assert stats["total_chunks"] == 0 + assert stats["total_files"] == 0 def test_get_stats_populated_db(self, full_db: str) -> None: """get_stats returns accurate counts for populated DB.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) stats = engine.get_stats() - assert stats['total_chunks'] == 6 - assert stats['total_files'] >= 1 - assert 'config' in stats + assert stats["total_chunks"] == 6 + assert stats["total_files"] >= 1 + assert "config" in stats # --------------------------------------------------------------------------- # TestAddVectorScoresToCandidates: _add_vector_scores_to_candidates() # --------------------------------------------------------------------------- + class TestAddVectorScoresToCandidates: """Tests for _add_vector_scores_to_candidates().""" - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_adds_vector_scores(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_adds_vector_scores( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """Adds vector_score and vector_distance to candidates.""" mock_array = Mock() mock_array.reshape.return_value = [[0.0]] mock_np.frombuffer.return_value = mock_array - mock_np.float32 = 'float32' + mock_np.float32 = "float32" mock_cosine.return_value = [[0.85]] - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) candidates: dict[int, dict[str, Any]] = { - 1: {'id': 1, 'sources': {}, 'metadata': {}}, + 1: {"id": 1, "sources": {}, "metadata": {}}, } engine._add_vector_scores_to_candidates(candidates, [[0.1, 0.1, 0.1, 0.1]], 0.5) # type: ignore[arg-type] # int candidate keys are valid test data (sqlite binds either) - assert 'vector_score' in candidates[1] - assert 'vector_distance' in candidates[1] - assert candidates[1]['sources'].get('vector_rerank') + assert "vector_score" in candidates[1] + assert "vector_distance" in candidates[1] + assert candidates[1]["sources"].get("vector_rerank") - @patch('signalwire.search.search_engine.np', None) + @patch("signalwire.search.search_engine.np", None) def test_no_numpy_does_nothing(self, full_db: str) -> None: """Does nothing when numpy is unavailable.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) - candidates: dict[int, dict[str, Any]] = {1: {'id': 1, 'sources': {}, 'metadata': {}}} + engine = SearchEngine(backend="sqlite", index_path=full_db) + candidates: dict[int, dict[str, Any]] = { + 1: {"id": 1, "sources": {}, "metadata": {}} + } engine._add_vector_scores_to_candidates(candidates, [[0.1]], 0.5) # type: ignore[arg-type] # int candidate keys are valid test data (sqlite binds either) - assert 'vector_score' not in candidates[1] + assert "vector_score" not in candidates[1] - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') - def test_empty_candidates(self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str) -> None: + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") + def test_empty_candidates( + self, mock_cosine: MagicMock, mock_np: MagicMock, full_db: str + ) -> None: """Does nothing with empty candidates dict.""" - engine = SearchEngine(backend='sqlite', index_path=full_db) + engine = SearchEngine(backend="sqlite", index_path=full_db) candidates: dict[int, dict[str, Any]] = {} engine._add_vector_scores_to_candidates(candidates, [[0.1]], 0.5) # type: ignore[arg-type] # int candidate keys are valid test data (sqlite binds either) assert candidates == {} - @patch('signalwire.search.search_engine.np') - @patch('signalwire.search.search_engine.cosine_similarity') + @patch("signalwire.search.search_engine.np") + @patch("signalwire.search.search_engine.cosine_similarity") def test_db_error_handled(self, mock_cosine: MagicMock, mock_np: MagicMock) -> None: """When the SQLite db cannot be opened (path doesn't exist), the method must catch the error and leave the candidates dict unchanged — specifically NO vector_score gets attached because no embedding row was ever read.""" - engine = SearchEngine(backend='sqlite', index_path='/nonexistent/path.db') - candidates: dict[int, dict[str, Any]] = {1: {'id': 1, 'sources': {}, 'metadata': {}}} + engine = SearchEngine(backend="sqlite", index_path="/nonexistent/path.db") + candidates: dict[int, dict[str, Any]] = { + 1: {"id": 1, "sources": {}, "metadata": {}} + } engine._add_vector_scores_to_candidates(candidates, [[0.1]], 0.5) # type: ignore[arg-type] # int candidate keys are valid test data (sqlite binds either) # Candidate dict still exists with same key. assert list(candidates.keys()) == [1] # But no vector score was attached because the read failed. - assert 'vector_score' not in candidates[1] - assert 'vector_distance' not in candidates[1] - assert 'vector_rerank' not in candidates[1]['sources'] \ No newline at end of file + assert "vector_score" not in candidates[1] + assert "vector_distance" not in candidates[1] + assert "vector_rerank" not in candidates[1]["sources"] diff --git a/tests/unit/search/test_search_service.py b/tests/unit/search/test_search_service.py index 84c968e9..8a44affe 100644 --- a/tests/unit/search/test_search_service.py +++ b/tests/unit/search/test_search_service.py @@ -15,9 +15,8 @@ import types import pytest import asyncio -import hashlib -import json -from unittest.mock import Mock, patch, MagicMock, AsyncMock, PropertyMock +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock from collections.abc import Coroutine, Iterator from typing import Any, TypeVar @@ -121,15 +120,28 @@ sys.modules.update(_sys_modules_snapshot) del _sys_modules_snapshot +# The real fastapi is back in sys.modules now, so this is the exact exception +# class SearchService raises at runtime (verified: `_handle_search`, +# `search_direct`, and `_get_current_username` all raise +# fastapi.exceptions.HTTPException). Imported from fastapi rather than +# re-exported through search_service, which mypy rejects under +# no_implicit_reexport. +from fastapi import HTTPException + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -def _make_search_request(query: str = "test query", index_name: str = "default", count: int = 3, - similarity_threshold: float = 0.0, - tags: list[str] | None = None, - language: str | None = None) -> Mock: + +def _make_search_request( + query: str = "test query", + index_name: str = "default", + count: int = 3, + similarity_threshold: float = 0.0, + tags: list[str] | None = None, + language: str | None = None, +) -> Mock: """Create a mock SearchRequest-like object.""" req = Mock() req.query = query @@ -154,14 +166,17 @@ def _run_async(coro: Coroutine[Any, Any, _T]) -> _T: # Fixture: patch external deps used by SearchService.__init__ # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def _patch_external_deps() -> Iterator[dict[str, MagicMock]]: """Patch external dependencies for every test so SearchService can be constructed.""" - with patch("signalwire.search.search_service.SecurityConfig") as mock_sec, \ - patch("signalwire.search.search_service.ConfigLoader") as mock_cl, \ - patch("signalwire.search.search_service.set_global_model"), \ - patch("signalwire.search.search_service.SearchEngine") as mock_se, \ - patch("signalwire.search.search_service.SentenceTransformer", None): + with ( + patch("signalwire.search.search_service.SecurityConfig") as mock_sec, + patch("signalwire.search.search_service.ConfigLoader") as mock_cl, + patch("signalwire.search.search_service.set_global_model"), + patch("signalwire.search.search_service.SearchEngine") as mock_se, + patch("signalwire.search.search_service.SentenceTransformer", None), + ): # SecurityConfig defaults sec_instance = MagicMock() sec_instance.get_basic_auth.return_value = ("user", "pass") @@ -194,6 +209,7 @@ def _patch_external_deps() -> Iterator[dict[str, MagicMock]]: # Tests for _cache_key helper # =================================================================== + class TestCacheKey: """Tests for the module-level _cache_key function.""" @@ -254,6 +270,7 @@ def test_cache_key_does_not_leak_query(self) -> None: # Tests for SearchService initialization # =================================================================== + class TestSearchServiceInit: """Tests for SearchService.__init__ and _load_config.""" @@ -288,16 +305,20 @@ def test_basic_auth_fallback_to_security_config(self) -> None: assert svc._basic_auth == ("user", "pass") def test_pgvector_backend(self) -> None: - svc = SearchService(backend="pgvector", connection_string="postgresql://localhost/db") + svc = SearchService( + backend="pgvector", connection_string="postgresql://localhost/db" + ) assert svc.backend == "pgvector" assert svc.connection_string == "postgresql://localhost/db" def test_security_config_created(self, _patch_external_deps: MagicMock) -> None: - svc = SearchService(config_file="/some/config.json") + SearchService(config_file="/some/config.json") _patch_external_deps["SecurityConfig"].assert_called_once_with( config_file="/some/config.json", service_name="search" ) - _patch_external_deps["security_instance"].log_config.assert_called_once_with("SearchService") + _patch_external_deps["security_instance"].log_config.assert_called_once_with( + "SearchService" + ) def test_no_fastapi_sets_app_none(self) -> None: # Patch FastAPI to None so the constructor skips app creation @@ -310,6 +331,7 @@ def test_no_fastapi_sets_app_none(self) -> None: # Tests for _load_config # =================================================================== + class TestLoadConfig: """Tests for _load_config method.""" @@ -320,7 +342,9 @@ def test_load_config_no_file(self, _patch_external_deps: MagicMock) -> None: assert svc.backend == "sqlite" assert svc.connection_string is None - def test_load_config_with_service_section(self, _patch_external_deps: MagicMock) -> None: + def test_load_config_with_service_section( + self, _patch_external_deps: MagicMock, tmp_path: Path + ) -> None: cl_instance = _patch_external_deps["ConfigLoader"].return_value cl_instance.has_config.return_value = True cl_instance.get_section.return_value = { @@ -329,21 +353,25 @@ def test_load_config_with_service_section(self, _patch_external_deps: MagicMock) "connection_string": "postgresql://localhost/mydb", "indexes": {"docs": "my_collection"}, } - _patch_external_deps["ConfigLoader"].find_config_file.return_value = "/tmp/config.json" + config_file = str(tmp_path / "config.json") + _patch_external_deps["ConfigLoader"].find_config_file.return_value = config_file - svc = SearchService(config_file="/tmp/config.json") + svc = SearchService(config_file=config_file) # The constructor overrides port with its own default 8001 after _load_config assert svc.port == 8001 - def test_load_config_indexes_only_when_dict(self, _patch_external_deps: MagicMock) -> None: + def test_load_config_indexes_only_when_dict( + self, _patch_external_deps: MagicMock, tmp_path: Path + ) -> None: cl_instance = _patch_external_deps["ConfigLoader"].return_value cl_instance.has_config.return_value = True cl_instance.get_section.return_value = { "indexes": "not_a_dict", } - _patch_external_deps["ConfigLoader"].find_config_file.return_value = "/tmp/config.json" + config_file = str(tmp_path / "config.json") + _patch_external_deps["ConfigLoader"].find_config_file.return_value = config_file - svc = SearchService(config_file="/tmp/config.json") + svc = SearchService(config_file=config_file) assert svc.indexes == {} @@ -351,6 +379,7 @@ def test_load_config_indexes_only_when_dict(self, _patch_external_deps: MagicMoc # Tests for _load_resources # =================================================================== + class TestLoadResources: """Tests for _load_resources method.""" @@ -359,33 +388,53 @@ def test_load_resources_sqlite_no_indexes(self) -> None: assert svc.model is None assert svc.search_engines == {} - def test_load_resources_sqlite_with_indexes(self, _patch_external_deps: MagicMock) -> None: + def test_load_resources_sqlite_with_indexes( + self, _patch_external_deps: MagicMock + ) -> None: mock_model = MagicMock() - with patch("signalwire.search.search_service.SentenceTransformer", return_value=mock_model): - with patch.object( - SearchService, "_get_model_name", return_value="sentence-transformers/all-mpnet-base-v2" - ): - svc = SearchService(indexes={"docs": "/path/docs.db"}) - assert svc.model == mock_model - - def test_load_resources_sqlite_model_load_failure(self, _patch_external_deps: MagicMock) -> None: - with patch( - "signalwire.search.search_service.SentenceTransformer", - side_effect=Exception("model load failed"), + with ( + patch( + "signalwire.search.search_service.SentenceTransformer", + return_value=mock_model, + ), + patch.object( + SearchService, + "_get_model_name", + return_value="sentence-transformers/all-mpnet-base-v2", + ), + ): + svc = SearchService(indexes={"docs": "/path/docs.db"}) + assert svc.model == mock_model + + def test_load_resources_sqlite_model_load_failure( + self, _patch_external_deps: MagicMock + ) -> None: + with ( + patch( + "signalwire.search.search_service.SentenceTransformer", + side_effect=Exception("model load failed"), + ), + patch.object( + SearchService, + "_get_model_name", + return_value="sentence-transformers/all-mpnet-base-v2", + ), ): - with patch.object( - SearchService, "_get_model_name", return_value="sentence-transformers/all-mpnet-base-v2" - ): - svc = SearchService(indexes={"docs": "/path/docs.db"}) - assert svc.model is None + svc = SearchService(indexes={"docs": "/path/docs.db"}) + assert svc.model is None - def test_load_resources_pgvector_creates_engines(self, _patch_external_deps: MagicMock) -> None: + def test_load_resources_pgvector_creates_engines( + self, _patch_external_deps: MagicMock + ) -> None: mock_engine = MagicMock() mock_engine.config = {"model_name": "test-model"} _patch_external_deps["SearchEngine"].return_value = mock_engine mock_model = MagicMock() - with patch("signalwire.search.search_service.SentenceTransformer", return_value=mock_model): + with patch( + "signalwire.search.search_service.SentenceTransformer", + return_value=mock_model, + ): svc = SearchService( backend="pgvector", connection_string="postgresql://localhost/db", @@ -393,8 +442,12 @@ def test_load_resources_pgvector_creates_engines(self, _patch_external_deps: Mag ) assert "col1" in svc.search_engines - def test_load_resources_pgvector_engine_failure(self, _patch_external_deps: MagicMock) -> None: - _patch_external_deps["SearchEngine"].side_effect = Exception("connection failed") + def test_load_resources_pgvector_engine_failure( + self, _patch_external_deps: MagicMock + ) -> None: + _patch_external_deps["SearchEngine"].side_effect = Exception( + "connection failed" + ) svc = SearchService( backend="pgvector", @@ -408,11 +461,14 @@ def test_load_resources_pgvector_engine_failure(self, _patch_external_deps: Magi # Tests for _get_model_name # =================================================================== + class TestGetModelName: """Tests for _get_model_name method.""" def test_pgvector_returns_default_model(self) -> None: - svc = SearchService(backend="pgvector", connection_string="postgresql://localhost/db") + svc = SearchService( + backend="pgvector", connection_string="postgresql://localhost/db" + ) result = svc._get_model_name("/some/path") assert result == "sentence-transformers/all-mpnet-base-v2" @@ -462,6 +518,7 @@ def test_sqlite_no_config_row_returns_default(self) -> None: # Tests for _handle_search # =================================================================== + class TestHandleSearch: """Tests for the async _handle_search method.""" @@ -486,9 +543,12 @@ def test_handle_search_index_not_found(self) -> None: svc = SearchService() request = _make_search_request(index_name="nonexistent") - with pytest.raises(Exception): + with pytest.raises(HTTPException) as excinfo: _run_async(svc._handle_search(request)) + assert excinfo.value.status_code == 404 + assert "nonexistent" in str(excinfo.value.detail) + def test_handle_search_success(self, service_with_engine: SearchService) -> None: with patch("signalwire.search.search_service.preprocess_query") as mock_pp: mock_pp.return_value = { @@ -508,7 +568,9 @@ def test_handle_search_success(self, service_with_engine: SearchService) -> None assert response.query_analysis["original_query"] == "test query" assert response.query_analysis["enhanced_query"] == "enhanced test query" - def test_handle_search_preprocessing_failure(self, service_with_engine: SearchService) -> None: + def test_handle_search_preprocessing_failure( + self, service_with_engine: SearchService + ) -> None: """When preprocess_query fails, search should still proceed with original query.""" with patch( "signalwire.search.search_service.preprocess_query", @@ -520,9 +582,13 @@ def test_handle_search_preprocessing_failure(self, service_with_engine: SearchSe assert response.query_analysis is not None assert response.query_analysis["enhanced_query"] == "test query" - def test_handle_search_engine_failure(self, service_with_engine: SearchService) -> None: + def test_handle_search_engine_failure( + self, service_with_engine: SearchService + ) -> None: """When search engine raises, should return empty results.""" - service_with_engine.search_engines["default"].search.side_effect = Exception("search error") # type: ignore[attr-defined] # mock attr + service_with_engine.search_engines["default"].search.side_effect = Exception( # type: ignore[attr-defined] # mock attr + "search error" + ) with patch("signalwire.search.search_service.preprocess_query") as mock_pp: mock_pp.return_value = { @@ -554,7 +620,9 @@ def test_handle_search_caching(self, service_with_engine: SearchService) -> None assert mock_pp.call_count == 1 assert resp1 is resp2 - def test_handle_search_cache_eviction(self, service_with_engine: SearchService) -> None: + def test_handle_search_cache_eviction( + self, service_with_engine: SearchService + ) -> None: """When cache is full, oldest entry should be evicted.""" service_with_engine._cache_size = 2 @@ -585,7 +653,9 @@ def test_handle_search_with_tags(self, service_with_engine: SearchService) -> No call_kwargs = service_with_engine.search_engines["default"].search.call_args # type: ignore[attr-defined] # mock attr assert call_kwargs[1]["tags"] == ["python"] - def test_handle_search_with_language(self, service_with_engine: SearchService) -> None: + def test_handle_search_with_language( + self, service_with_engine: SearchService + ) -> None: with patch("signalwire.search.search_service.preprocess_query") as mock_pp: mock_pp.return_value = { "enhanced_text": "test", @@ -600,7 +670,9 @@ def test_handle_search_with_language(self, service_with_engine: SearchService) - call_kwargs = mock_pp.call_args assert call_kwargs[1]["language"] == "es" - def test_handle_search_auto_language(self, service_with_engine: SearchService) -> None: + def test_handle_search_auto_language( + self, service_with_engine: SearchService + ) -> None: with patch("signalwire.search.search_service.preprocess_query") as mock_pp: mock_pp.return_value = { "enhanced_text": "test", @@ -614,7 +686,9 @@ def test_handle_search_auto_language(self, service_with_engine: SearchService) - call_kwargs = mock_pp.call_args assert call_kwargs[1]["language"] == "auto" - def test_handle_search_similarity_threshold(self, service_with_engine: SearchService) -> None: + def test_handle_search_similarity_threshold( + self, service_with_engine: SearchService + ) -> None: with patch("signalwire.search.search_service.preprocess_query") as mock_pp: mock_pp.return_value = { "enhanced_text": "test", @@ -633,6 +707,7 @@ def test_handle_search_similarity_threshold(self, service_with_engine: SearchSer # Tests for _handle_search with pgvector backend # =================================================================== + class TestHandleSearchPgvector: """Tests for _handle_search with pgvector-specific behavior.""" @@ -650,8 +725,10 @@ def test_pgvector_sets_global_model(self) -> None: svc.models = {"test-model": mock_model} svc.collection_models = {"default": "test-model"} - with patch("signalwire.search.search_service.preprocess_query") as mock_pp, \ - patch("signalwire.search.search_service.set_global_model") as mock_sgm: + with ( + patch("signalwire.search.search_service.preprocess_query") as mock_pp, + patch("signalwire.search.search_service.set_global_model") as mock_sgm, + ): mock_pp.return_value = { "enhanced_text": "test", "vector": [0.1], @@ -668,6 +745,7 @@ def test_pgvector_sets_global_model(self) -> None: # Tests for _get_current_username (auth validation) # =================================================================== + class TestGetCurrentUsername: """Tests for basic auth credential validation.""" @@ -693,11 +771,11 @@ def test_invalid_username(self) -> None: creds.username = "wrong" creds.password = "secret" - # HTTPException is None in this test environment (no FastAPI), - # so the code will try to call None(...) which raises TypeError - with pytest.raises(Exception): + with pytest.raises(HTTPException) as excinfo: svc._get_current_username(credentials=creds) + assert excinfo.value.status_code == 401 + def test_invalid_password(self) -> None: svc = SearchService(basic_auth=("admin", "secret")) @@ -705,9 +783,11 @@ def test_invalid_password(self) -> None: creds.username = "admin" creds.password = "wrong" - with pytest.raises(Exception): + with pytest.raises(HTTPException) as excinfo: svc._get_current_username(credentials=creds) + assert excinfo.value.status_code == 401 + def test_timing_safe_comparison_used(self) -> None: """Verify that secrets.compare_digest is used (timing-safe).""" svc = SearchService(basic_auth=("admin", "secret")) @@ -725,6 +805,7 @@ def test_timing_safe_comparison_used(self) -> None: # Tests for search_direct (sync wrapper) # =================================================================== + class TestSearchDirect: """Tests for the synchronous search_direct method.""" @@ -786,14 +867,18 @@ def test_search_direct_passes_parameters(self) -> None: def test_search_direct_index_not_found(self) -> None: svc = SearchService() - with pytest.raises(Exception): + with pytest.raises(HTTPException) as excinfo: svc.search_direct("test", index_name="nonexistent") + assert excinfo.value.status_code == 404 + assert "nonexistent" in str(excinfo.value.detail) + # =================================================================== # Tests for start/stop # =================================================================== + class TestStartStop: """Tests for start and stop methods.""" @@ -826,8 +911,12 @@ def test_start_with_ssl_cert_and_key(self) -> None: assert call_args[1]["ssl_certfile"] == "/path/cert.pem" assert call_args[1]["ssl_keyfile"] == "/path/key.pem" - def test_start_with_security_config_ssl(self, _patch_external_deps: MagicMock) -> None: - _patch_external_deps["security_instance"].get_ssl_context_kwargs.return_value = { + def test_start_with_security_config_ssl( + self, _patch_external_deps: MagicMock + ) -> None: + _patch_external_deps[ + "security_instance" + ].get_ssl_context_kwargs.return_value = { "ssl_certfile": "/auto/cert.pem", "ssl_keyfile": "/auto/key.pem", } @@ -878,6 +967,7 @@ def test_stop_does_not_raise(self) -> None: # Tests for _setup_security # =================================================================== + class TestSetupSecurity: """Tests for security middleware setup.""" @@ -893,6 +983,7 @@ def test_setup_security_no_app(self) -> None: # Tests for _setup_routes # =================================================================== + class TestSetupRoutes: """Tests for route setup.""" @@ -908,6 +999,7 @@ def test_setup_routes_no_app(self) -> None: # Tests for Pydantic / fallback model classes # =================================================================== + class TestSearchModels: """Tests for the SearchRequest/SearchResult/SearchResponse fallback classes.""" @@ -961,6 +1053,7 @@ def test_search_response_no_analysis(self) -> None: # Tests for edge cases and security # =================================================================== + class TestEdgeCasesAndSecurity: """Tests for edge cases, error handling, and security considerations.""" @@ -1190,6 +1283,7 @@ def test_handle_search_no_engine_config_attr(self) -> None: # Tests for response format # =================================================================== + class TestResponseFormat: """Verify the shape of responses from _handle_search.""" @@ -1260,6 +1354,7 @@ def test_response_results_have_correct_shape(self) -> None: # Tests for search_direct result format # =================================================================== + class TestSearchDirectResultFormat: """Verify search_direct returns dict with correct shape.""" diff --git a/tests/unit/security/test_swaig_secure_token_required.py b/tests/unit/security/test_swaig_secure_token_required.py new file mode 100644 index 00000000..b457e4e9 --- /dev/null +++ b/tests/unit/security/test_swaig_secure_token_required.py @@ -0,0 +1,254 @@ +""" +Copyright (c) 2025 SignalWire + +This file is part of the SignalWire SDK. + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. + +Contract: a SWAIG tool registered with ``secure=True`` REQUIRES a ``__token``. + +These are real-HTTP tests: a real ``AgentBase`` is mounted through FastAPI's +``TestClient`` and driven over the ASGI stack, with a real ``SessionManager`` +minting genuine HMAC tokens. Nothing about the token path is stubbed. + +The three cases the contract pins down, for a ``secure=True`` tool: + + (i) valid token -> accepted, the handler RUNS + (ii) invalid token -> REFUSED, the handler does NOT run + (iii) ABSENT token -> REFUSED, the handler does NOT run + +Case (iii) is the security fix. Before it, an absent token skipped validation +entirely and a ``secure`` tool executed unauthenticated -- a flag named +``secure`` that permits anonymous calls is a trap. + +The refusal SHAPE is deliberately identical for (ii) and (iii): HTTP 200 with a +``FunctionResult`` body carrying a ``response`` string. The engine (mod_openai) +has no special handling for a SWAIG refusal -- it has no notion of a 401/403 +from a tool -- so the tool simply reports that it cannot execute and the model +relays that to the caller. Do not "improve" this into a status code. + +A ``secure=False`` tool must still run with no token at all; that is the whole +point of the flag being a flag. +""" + +import base64 +from typing import Any + +from fastapi.testclient import TestClient + +from signalwire.core.agent_base import AgentBase +from signalwire.core.function_result import FunctionResult + + +CALL_ID = "call-abc-123" + + +class _ProbeAgent(AgentBase): + """Agent exposing one secure and one insecure tool, each recording calls.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.secure_calls: list[dict[str, Any]] = [] + self.open_calls: list[dict[str, Any]] = [] + + self.define_tool( + name="vault_balance", + description="Read the caller's vault balance.", + parameters={"type": "object", "properties": {}}, + handler=self._secure_handler, + secure=True, + ) + self.define_tool( + name="store_hours", + description="Read the public store hours.", + parameters={"type": "object", "properties": {}}, + handler=self._open_handler, + secure=False, + ) + + def _secure_handler(self, args: dict[str, Any], raw_data: Any) -> FunctionResult: + self.secure_calls.append(args) + return FunctionResult("SECRET-BALANCE-9999") + + def _open_handler(self, args: dict[str, Any], raw_data: Any) -> FunctionResult: + self.open_calls.append(args) + return FunctionResult("open 9 to 5") + + +def _basic_auth_headers(agent: AgentBase) -> dict[str, str]: + creds = agent.get_basic_auth_credentials() + token = base64.b64encode(f"{creds[0]}:{creds[1]}".encode()).decode() + return { + "Authorization": f"Basic {token}", + "content-type": "application/json", + } + + +def _post_swaig( + client: TestClient, + agent: AgentBase, + function_name: str, + query: str = "", +) -> Any: + """POST a SWAIG function call, optionally with a token in the query string.""" + return client.post( + f"/swaig{query}", + json={ + "function": function_name, + "argument": {"parsed": [{}], "raw": "{}"}, + "call_id": CALL_ID, + }, + headers=_basic_auth_headers(agent), + ) + + +def _agent_and_client() -> tuple[_ProbeAgent, TestClient]: + agent = _ProbeAgent(name="secure-probe") + return agent, TestClient(agent.get_app()) + + +def _valid_token(agent: AgentBase, function_name: str) -> str: + """Mint a genuine HMAC token from the agent's own live SessionManager.""" + return str(agent._session_manager.create_tool_token(function_name, CALL_ID)) + + +def _is_refusal(payload: dict[str, Any]) -> bool: + text = str(payload.get("response", "")).lower() + return "security token" in text and "cannot execute" in text + + +# --------------------------------------------------------------------------- +# (i) valid token -> accepted +# --------------------------------------------------------------------------- + + +class TestSecureToolValidToken: + def test_valid_token_runs_the_handler(self) -> None: + agent, client = _agent_and_client() + token = _valid_token(agent, "vault_balance") + + resp = _post_swaig(client, agent, "vault_balance", f"?__token={token}") + + assert resp.status_code == 200, resp.text + assert resp.json()["response"] == "SECRET-BALANCE-9999", resp.text + assert agent.secure_calls, "secure handler was not invoked with a valid token" + + +# --------------------------------------------------------------------------- +# (ii) invalid token -> refused (pre-existing behaviour; guard against regression) +# --------------------------------------------------------------------------- + + +class TestSecureToolInvalidToken: + def test_invalid_token_is_refused(self) -> None: + agent, client = _agent_and_client() + + resp = _post_swaig(client, agent, "vault_balance", "?__token=not-a-real-token") + + assert resp.status_code == 200, resp.text + assert _is_refusal(resp.json()), resp.text + assert not agent.secure_calls, ( + "secure handler RAN despite an invalid token: " + resp.text + ) + + def test_invalid_legacy_token_param_is_refused(self) -> None: + """The legacy ``token`` query param is honoured as a fallback.""" + agent, client = _agent_and_client() + + resp = _post_swaig(client, agent, "vault_balance", "?token=not-a-real-token") + + assert resp.status_code == 200, resp.text + assert _is_refusal(resp.json()), resp.text + assert not agent.secure_calls + + +# --------------------------------------------------------------------------- +# (iii) ABSENT token -> refused <- the security fix +# --------------------------------------------------------------------------- + + +class TestSecureToolAbsentToken: + def test_absent_token_is_refused(self) -> None: + """A ``secure=True`` tool must NOT execute when no token is supplied. + + Previously the entire validation block sat inside ``if token:`` -- so a + request with no token skipped validation and the secure tool ran + unauthenticated. Omitting the credential must never be weaker than + presenting a wrong one. + """ + agent, client = _agent_and_client() + + resp = _post_swaig(client, agent, "vault_balance") + + assert resp.status_code == 200, resp.text + assert _is_refusal(resp.json()), resp.text + assert not agent.secure_calls, ( + "SECURITY: secure handler RAN with NO token at all: " + resp.text + ) + + def test_absent_token_does_not_leak_the_secure_payload(self) -> None: + agent, client = _agent_and_client() + + resp = _post_swaig(client, agent, "vault_balance") + + assert "SECRET-BALANCE-9999" not in resp.text + + def test_empty_token_is_refused(self) -> None: + """An empty ``__token=`` is absent, not present-and-valid.""" + agent, client = _agent_and_client() + + resp = _post_swaig(client, agent, "vault_balance", "?__token=") + + assert resp.status_code == 200, resp.text + assert _is_refusal(resp.json()), resp.text + assert not agent.secure_calls + + +# --------------------------------------------------------------------------- +# The flag must remain a flag: secure=False + no token must still run +# --------------------------------------------------------------------------- + + +class TestInsecureToolStillRuns: + def test_non_secure_tool_runs_without_a_token(self) -> None: + agent, client = _agent_and_client() + + resp = _post_swaig(client, agent, "store_hours") + + assert resp.status_code == 200, resp.text + assert resp.json()["response"] == "open 9 to 5", resp.text + assert agent.open_calls, "insecure handler must run with no token" + + def test_non_secure_tool_runs_with_an_invalid_token(self) -> None: + agent, client = _agent_and_client() + + resp = _post_swaig(client, agent, "store_hours", "?__token=garbage") + + assert resp.status_code == 200, resp.text + assert resp.json()["response"] == "open 9 to 5", resp.text + assert agent.open_calls + + +# --------------------------------------------------------------------------- +# Refusal shape parity: absent and invalid must be indistinguishable +# --------------------------------------------------------------------------- + + +class TestRefusalShapeParity: + def test_absent_and_invalid_refusals_are_the_same_shape(self) -> None: + """The engine has no refusal protocol -- both must be a 200 + FunctionResult. + + If these ever diverge, a port could reasonably conclude that one of + them is allowed to be an HTTP error, which the engine cannot consume. + """ + agent_a, client_a = _agent_and_client() + absent = _post_swaig(client_a, agent_a, "vault_balance") + + agent_b, client_b = _agent_and_client() + invalid = _post_swaig(client_b, agent_b, "vault_balance", "?__token=bogus") + + assert absent.status_code == invalid.status_code == 200 + assert absent.json() == invalid.json(), ( + f"refusal shapes diverged: absent={absent.text} invalid={invalid.text}" + ) diff --git a/tests/unit/security/test_webhook_agent_integration.py b/tests/unit/security/test_webhook_agent_integration.py index 5ad90720..49cca108 100644 --- a/tests/unit/security/test_webhook_agent_integration.py +++ b/tests/unit/security/test_webhook_agent_integration.py @@ -52,6 +52,7 @@ def _basic_auth_headers(agent: AgentBase) -> dict[str, str]: # Signed: valid signature → 200, invalid → 403 # --------------------------------------------------------------------------- + class TestAgentSignedWebhooks: def test_post_swaig_with_valid_signature_runs_handler(self) -> None: agent = AgentBase(name="t1", signing_key=SIGNING_KEY) @@ -132,6 +133,7 @@ def test_unsigned_agent_accepts_any_request(self) -> None: # Startup warning when key is unset # --------------------------------------------------------------------------- + class _CaptureHandler(logging.Handler): """Minimal handler used to harvest LogRecords from the SDK's namespaced logger. @@ -150,7 +152,9 @@ def emit(self, record: logging.LogRecord) -> None: class TestAgentNoKeyWarning: - def test_warning_emitted_when_signing_key_unset(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_warning_emitted_when_signing_key_unset( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: """AgentBase logs a prominent WARNING when neither arg nor env is set.""" monkeypatch.delenv("SIGNALWIRE_SIGNING_KEY", raising=False) @@ -163,7 +167,8 @@ def test_warning_emitted_when_signing_key_unset(self, monkeypatch: pytest.Monkey agent_logger.removeHandler(capture) warning_records = [ - r for r in capture.records + r + for r in capture.records if r.levelno >= logging.WARNING and ( "webhook_signature_validation_disabled" in r.getMessage() @@ -187,7 +192,8 @@ def test_no_warning_when_key_is_set(self, monkeypatch: pytest.MonkeyPatch) -> No agent_logger.removeHandler(capture) warning_records = [ - r for r in capture.records + r + for r in capture.records if r.levelno >= logging.WARNING and ( "webhook_signature_validation_disabled" in r.getMessage() @@ -203,8 +209,11 @@ def test_no_warning_when_key_is_set(self, monkeypatch: pytest.MonkeyPatch) -> No # Env var fallback # --------------------------------------------------------------------------- + class TestAgentEnvFallback: - def test_env_signing_key_picked_up_when_no_explicit_arg(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_env_signing_key_picked_up_when_no_explicit_arg( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: """SIGNALWIRE_SIGNING_KEY env var supplies the key when arg is omitted.""" monkeypatch.setenv("SIGNALWIRE_SIGNING_KEY", SIGNING_KEY) agent = AgentBase(name="envtest") diff --git a/tests/unit/security/test_webhook_middleware.py b/tests/unit/security/test_webhook_middleware.py index 21cd8b0b..9d0b627d 100644 --- a/tests/unit/security/test_webhook_middleware.py +++ b/tests/unit/security/test_webhook_middleware.py @@ -9,7 +9,6 @@ import base64 import hashlib import hmac -from typing import Optional import pytest from fastapi import Depends, FastAPI, Request @@ -34,7 +33,7 @@ def _scheme_a_signature(key: str, url: str, raw_body: str) -> str: def _scheme_b_signature( - key: str, url: str, params: Optional[dict[str, object]] = None + key: str, url: str, params: dict[str, object] | None = None ) -> str: """Sign url + sortedConcatParams with HMAC-SHA1, base64 encode.""" params = params or {} @@ -69,6 +68,7 @@ async def webhook(request: Request) -> dict[str, object]: # 403 on invalid / missing # --------------------------------------------------------------------------- + class TestInvalidSignature: def test_invalid_signature_returns_403(self, signed_app: FastAPI) -> None: client = TestClient(signed_app) @@ -97,6 +97,7 @@ def test_missing_signature_header_returns_403(self, signed_app: FastAPI) -> None # 200 on valid + raw body forwarded # --------------------------------------------------------------------------- + class TestValidSignature: def test_valid_scheme_a_signature_passes_through(self, signed_app: FastAPI) -> None: client = TestClient(signed_app, base_url="http://testserver") @@ -139,6 +140,7 @@ def test_twilio_compat_header_alias_accepted(self, signed_app: FastAPI) -> None: # Construction errors # --------------------------------------------------------------------------- + class TestDependencyFactory: def test_empty_signing_key_raises(self) -> None: """make_webhook_validation_dependency rejects empty signing_key at build time.""" @@ -147,7 +149,9 @@ def test_empty_signing_key_raises(self) -> None: with pytest.raises(ValueError): make_webhook_validation_dependency(None) # type: ignore[arg-type] # intentional invalid input - def test_proxy_url_base_env_used(self, signed_app: FastAPI, monkeypatch: pytest.MonkeyPatch) -> None: + def test_proxy_url_base_env_used( + self, signed_app: FastAPI, monkeypatch: pytest.MonkeyPatch + ) -> None: """SWML_PROXY_URL_BASE env wins over request.url for URL reconstruction. We sign against the proxy URL and POST to the test app — if the diff --git a/tests/unit/security/test_webhook_validator.py b/tests/unit/security/test_webhook_validator.py index 4572b6b8..b5cf9708 100644 --- a/tests/unit/security/test_webhook_validator.py +++ b/tests/unit/security/test_webhook_validator.py @@ -33,8 +33,7 @@ "signing_key": "PSKtest1234567890abcdef", "url": "https://example.ngrok.io/webhook", "raw_body": ( - '{"event":"call.state","params":' - '{"call_id":"abc-123","state":"answered"}}' + '{"event":"call.state","params":{"call_id":"abc-123","state":"answered"}}' ), "expected": "c3c08c1fefaf9ee198a100d5906765a6f394bf0f", } @@ -80,6 +79,7 @@ def _form_encoded(params: dict[str, Any]) -> str: # Scheme A — RELAY/JSON (hex) # --------------------------------------------------------------------------- + class TestSchemeA: def test_positive_canonical_vector(self) -> None: """Vector A: known JSON body + URL + key produces the known hex digest.""" @@ -135,6 +135,7 @@ def test_negative_wrong_url(self) -> None: # Scheme B — Compat/cXML (base64 form) # --------------------------------------------------------------------------- + class TestSchemeB: def test_positive_canonical_form_vector(self) -> None: """Vector B: form params via raw body → matches the canonical Twilio digest.""" @@ -207,6 +208,7 @@ def test_body_sha256_mismatch_rejected(self) -> None: # URL port normalization # --------------------------------------------------------------------------- + class TestUrlPortNormalization: def _b64_sig(self, key: str, url: str, params: dict[str, Any] | None = None) -> str: params = params or {} @@ -224,21 +226,17 @@ def test_signature_with_port_accepted_when_request_has_no_port(self) -> None: url_without_port = "https://example.com/webhook" sig = self._b64_sig(key, url_with_port) # raw_body is a non-form body; Scheme B falls back to empty params. - assert ( - validate_webhook_signature(key, sig, url_without_port, "{}") - is True - ) + assert validate_webhook_signature(key, sig, url_without_port, "{}") is True - def test_signature_without_port_accepted_when_request_has_standard_port(self) -> None: + def test_signature_without_port_accepted_when_request_has_standard_port( + self, + ) -> None: """Backend signed without port — request URL has :443 → accept.""" key = "test-key" url_with_port = "https://example.com:443/webhook" url_without_port = "https://example.com/webhook" sig = self._b64_sig(key, url_without_port) - assert ( - validate_webhook_signature(key, sig, url_with_port, "{}") - is True - ) + assert validate_webhook_signature(key, sig, url_with_port, "{}") is True def test_http_port_80_normalization(self) -> None: """http + :80 mirrors https + :443.""" @@ -246,16 +244,14 @@ def test_http_port_80_normalization(self) -> None: url_with_port = "http://example.com:80/path" url_without_port = "http://example.com/path" sig = self._b64_sig(key, url_with_port) - assert ( - validate_webhook_signature(key, sig, url_without_port, "") - is True - ) + assert validate_webhook_signature(key, sig, url_without_port, "") is True # --------------------------------------------------------------------------- # Repeated form keys # --------------------------------------------------------------------------- + class TestRepeatedFormKeys: def test_repeated_keys_concat_in_submission_order(self) -> None: """``To=a&To=b`` → signing string ``URL + ToaTob``, deterministic.""" @@ -267,10 +263,7 @@ def test_repeated_keys_concat_in_submission_order(self) -> None: sig = base64.b64encode( hmac.new(key.encode(), expected_data.encode(), hashlib.sha1).digest() ).decode() - assert ( - validate_webhook_signature(key, sig, url, body) - is True - ) + assert validate_webhook_signature(key, sig, url, body) is True def test_repeated_keys_swapped_order_is_a_different_signature(self) -> None: """``To=b&To=a`` is a different submission and yields a different digest.""" @@ -292,6 +285,7 @@ def test_repeated_keys_swapped_order_is_a_different_signature(self) -> None: # Error modes # --------------------------------------------------------------------------- + class TestErrorModes: def test_missing_signature_returns_false(self) -> None: """Empty / None signature header → False, no exception.""" @@ -355,6 +349,7 @@ def test_malformed_signature_returns_false_without_throwing(self) -> None: # validate_request legacy alias dispatch # --------------------------------------------------------------------------- + class TestValidateRequestDispatch: def test_string_arg_delegates_to_combined_validator(self) -> None: """A string 4th arg behaves identically to validate_webhook_signature.""" @@ -395,6 +390,7 @@ def test_invalid_arg_type_raises_type_error(self) -> None: # Constant-time compare — read the source, not just the result # --------------------------------------------------------------------------- + class TestConstantTimeCompare: def test_validator_source_uses_hmac_compare_digest(self) -> None: """The implementation must call ``hmac.compare_digest`` for all sig comparisons. diff --git a/tests/unit/skills/conftest.py b/tests/unit/skills/conftest.py index bed58b0b..a51ed02f 100644 --- a/tests/unit/skills/conftest.py +++ b/tests/unit/skills/conftest.py @@ -8,6 +8,7 @@ configure logging once for this test package. This mirrors what a real deployment does at its serve/run entry point. """ + from __future__ import annotations import pytest diff --git a/tests/unit/skills/test_api_ninjas_trivia_skill.py b/tests/unit/skills/test_api_ninjas_trivia_skill.py index cbf3614b..1176d9b9 100644 --- a/tests/unit/skills/test_api_ninjas_trivia_skill.py +++ b/tests/unit/skills/test_api_ninjas_trivia_skill.py @@ -38,6 +38,7 @@ def _make_skill(params: dict[str, Any] | None = None) -> ApiNinjasTriviaSkill: # Class-level attributes # --------------------------------------------------------------------------- + class TestApiNinjasTriviaSkillClassAttributes: """Verify class-level constants and metadata.""" @@ -45,7 +46,10 @@ def test_skill_name(self) -> None: assert ApiNinjasTriviaSkill.SKILL_NAME == "api_ninjas_trivia" def test_skill_description(self) -> None: - assert ApiNinjasTriviaSkill.SKILL_DESCRIPTION == "Get trivia questions from API Ninjas" + assert ( + ApiNinjasTriviaSkill.SKILL_DESCRIPTION + == "Get trivia questions from API Ninjas" + ) def test_skill_version(self) -> None: assert ApiNinjasTriviaSkill.SKILL_VERSION == "1.0.0" @@ -64,10 +68,20 @@ def test_valid_categories_count(self) -> None: def test_valid_categories_contains_expected_keys(self) -> None: expected = [ - "artliterature", "language", "sciencenature", "general", - "fooddrink", "peopleplaces", "geography", "historyholidays", - "entertainment", "toysgames", "music", "mathematics", - "religionmythology", "sportsleisure" + "artliterature", + "language", + "sciencenature", + "general", + "fooddrink", + "peopleplaces", + "geography", + "historyholidays", + "entertainment", + "toysgames", + "music", + "mathematics", + "religionmythology", + "sportsleisure", ] for key in expected: assert key in ApiNinjasTriviaSkill.VALID_CATEGORIES @@ -77,6 +91,7 @@ def test_valid_categories_contains_expected_keys(self) -> None: # Initialization and Validation # --------------------------------------------------------------------------- + class TestApiNinjasTriviaSkillInit: """Tests for __init__ and _validate_config.""" @@ -135,30 +150,44 @@ def test_non_string_api_key_raises(self) -> None: ApiNinjasTriviaSkill(agent=mock_agent, params={"api_key": 12345}) def test_empty_categories_list_raises(self) -> None: - with pytest.raises(ValueError, match="categories parameter must be a non-empty list"): + with pytest.raises( + ValueError, match="categories parameter must be a non-empty list" + ): mock_agent = Mock() - ApiNinjasTriviaSkill(agent=mock_agent, params={"api_key": "key", "categories": []}) + ApiNinjasTriviaSkill( + agent=mock_agent, params={"api_key": "key", "categories": []} + ) def test_non_list_categories_raises(self) -> None: - with pytest.raises(ValueError, match="categories parameter must be a non-empty list"): + with pytest.raises( + ValueError, match="categories parameter must be a non-empty list" + ): mock_agent = Mock() - ApiNinjasTriviaSkill(agent=mock_agent, params={"api_key": "key", "categories": "music"}) + ApiNinjasTriviaSkill( + agent=mock_agent, params={"api_key": "key", "categories": "music"} + ) def test_invalid_category_raises(self) -> None: with pytest.raises(ValueError, match="Category 'invalid_cat' is not valid"): mock_agent = Mock() - ApiNinjasTriviaSkill(agent=mock_agent, params={"api_key": "key", "categories": ["invalid_cat"]}) + ApiNinjasTriviaSkill( + agent=mock_agent, + params={"api_key": "key", "categories": ["invalid_cat"]}, + ) def test_non_string_category_raises(self) -> None: with pytest.raises(ValueError, match="Category 0 must be a string"): mock_agent = Mock() - ApiNinjasTriviaSkill(agent=mock_agent, params={"api_key": "key", "categories": [123]}) + ApiNinjasTriviaSkill( + agent=mock_agent, params={"api_key": "key", "categories": [123]} + ) # --------------------------------------------------------------------------- # setup() # --------------------------------------------------------------------------- + class TestApiNinjasTriviaSkillSetup: """Tests for the setup method.""" @@ -171,6 +200,7 @@ def test_setup_returns_true(self) -> None: # register_tools() # --------------------------------------------------------------------------- + class TestApiNinjasTriviaSkillRegisterTools: """Tests for register_tools method.""" @@ -196,6 +226,7 @@ def test_register_tools_merges_swaig_fields(self) -> None: # get_tools() # --------------------------------------------------------------------------- + class TestApiNinjasTriviaSkillGetTools: """Tests for the get_tools method.""" @@ -249,7 +280,10 @@ def test_tool_webhook_url_correct(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] webhook = tool["data_map"]["webhooks"][0] - assert webhook["url"] == "https://api.api-ninjas.com/v1/trivia?category=%{args.category}" + assert ( + webhook["url"] + == "https://api.api-ninjas.com/v1/trivia?category=%{args.category}" + ) def test_tool_webhook_method_is_get(self) -> None: skill = _make_skill() @@ -297,6 +331,7 @@ def test_category_description_includes_human_readable(self) -> None: # get_instance_key() # --------------------------------------------------------------------------- + class TestApiNinjasTriviaSkillInstanceKey: """Tests for get_instance_key method.""" @@ -313,6 +348,7 @@ def test_custom_instance_key(self) -> None: # get_hints(), get_prompt_sections() # --------------------------------------------------------------------------- + class TestApiNinjasTriviaSkillPromptMethods: """Tests for prompt-related methods inherited from SkillBase.""" @@ -333,6 +369,7 @@ def test_get_global_data_returns_empty_dict(self) -> None: # get_parameter_schema() # --------------------------------------------------------------------------- + class TestApiNinjasTriviaSkillParameterSchema: """Tests for get_parameter_schema class method.""" @@ -366,7 +403,9 @@ def test_categories_not_required(self) -> None: def test_categories_default_is_all(self) -> None: schema = ApiNinjasTriviaSkill.get_parameter_schema() - assert schema["categories"]["default"] == list(ApiNinjasTriviaSkill.VALID_CATEGORIES.keys()) + assert schema["categories"]["default"] == list( + ApiNinjasTriviaSkill.VALID_CATEGORIES.keys() + ) def test_categories_items_has_enum(self) -> None: schema = ApiNinjasTriviaSkill.get_parameter_schema() diff --git a/tests/unit/skills/test_claude_skills_skill.py b/tests/unit/skills/test_claude_skills_skill.py index d2898435..7fb7c74c 100644 --- a/tests/unit/skills/test_claude_skills_skill.py +++ b/tests/unit/skills/test_claude_skills_skill.py @@ -9,6 +9,7 @@ Unit tests for the Claude Skills skill module. """ +import sys import tempfile from pathlib import Path from typing import Any @@ -64,6 +65,7 @@ def _write_skill_md( # Class-level attributes # --------------------------------------------------------------------------- + class TestClassAttributes: """Verify class-level constants and metadata.""" @@ -71,7 +73,10 @@ def test_skill_name(self) -> None: assert ClaudeSkillsSkill.SKILL_NAME == "claude_skills" def test_skill_description(self) -> None: - assert ClaudeSkillsSkill.SKILL_DESCRIPTION == "Load Claude SKILL.md files as agent tools" + assert ( + ClaudeSkillsSkill.SKILL_DESCRIPTION + == "Load Claude SKILL.md files as agent tools" + ) def test_skill_version(self) -> None: assert ClaudeSkillsSkill.SKILL_VERSION == "1.0.0" @@ -90,6 +95,7 @@ def test_supports_multiple_instances(self) -> None: # Frontmatter parsing # --------------------------------------------------------------------------- + class TestParseSkillMd: """Test frontmatter parsing including all spec fields.""" @@ -115,7 +121,11 @@ def test_license_field(self) -> None: def test_compatibility_field(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: skill_dir = Path(tmpdir) / "my-skill" - _write_skill_md(skill_dir, "my-skill", extra_frontmatter={"compatibility": "claude-code >= 1.0"}) + _write_skill_md( + skill_dir, + "my-skill", + extra_frontmatter={"compatibility": "claude-code >= 1.0"}, + ) skill = _make_skill({"skills_path": tmpdir}) skill.setup() assert skill._skills[0]["compatibility"] == "claude-code >= 1.0" @@ -123,7 +133,9 @@ def test_compatibility_field(self) -> None: def test_context_field(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: skill_dir = Path(tmpdir) / "my-skill" - _write_skill_md(skill_dir, "my-skill", extra_frontmatter={"context": "fork"}) + _write_skill_md( + skill_dir, "my-skill", extra_frontmatter={"context": "fork"} + ) skill = _make_skill({"skills_path": tmpdir}) skill.setup() assert skill._skills[0]["context"] == "fork" @@ -131,7 +143,9 @@ def test_context_field(self) -> None: def test_agent_field(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: skill_dir = Path(tmpdir) / "my-skill" - _write_skill_md(skill_dir, "my-skill", extra_frontmatter={"agent": "Explore"}) + _write_skill_md( + skill_dir, "my-skill", extra_frontmatter={"agent": "Explore"} + ) skill = _make_skill({"skills_path": tmpdir}) skill.setup() assert skill._skills[0]["agent"] == "Explore" @@ -139,7 +153,9 @@ def test_agent_field(self) -> None: def test_allowed_tools_field(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: skill_dir = Path(tmpdir) / "my-skill" - _write_skill_md(skill_dir, "my-skill", extra_frontmatter={"allowed-tools": "Read, Grep"}) + _write_skill_md( + skill_dir, "my-skill", extra_frontmatter={"allowed-tools": "Read, Grep"} + ) skill = _make_skill({"skills_path": tmpdir}) skill.setup() assert skill._skills[0]["allowed_tools"] == "Read, Grep" @@ -155,7 +171,9 @@ def test_model_field(self) -> None: def test_hooks_field(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: skill_dir = Path(tmpdir) / "my-skill" - _write_skill_md(skill_dir, "my-skill", extra_frontmatter={"hooks": "pre-run"}) + _write_skill_md( + skill_dir, "my-skill", extra_frontmatter={"hooks": "pre-run"} + ) skill = _make_skill({"skills_path": tmpdir}) skill.setup() assert skill._skills[0]["hooks"] == "pre-run" @@ -175,14 +193,18 @@ def test_no_frontmatter(self) -> None: # Invocation control # --------------------------------------------------------------------------- + class TestInvocationControl: """Test disable-model-invocation and user-invocable flags.""" def test_disable_model_invocation_skips_tool_and_prompt(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: skill_dir = Path(tmpdir) / "disabled-skill" - _write_skill_md(skill_dir, "disabled-skill", - extra_frontmatter={"disable-model-invocation": True}) + _write_skill_md( + skill_dir, + "disabled-skill", + extra_frontmatter={"disable-model-invocation": True}, + ) skill = _make_skill({"skills_path": tmpdir}) skill.setup() @@ -198,8 +220,12 @@ def test_disable_model_invocation_skips_tool_and_prompt(self) -> None: def test_user_invocable_false_skips_tool_keeps_prompt(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: skill_dir = Path(tmpdir) / "knowledge-skill" - _write_skill_md(skill_dir, "knowledge-skill", body="Knowledge content", - extra_frontmatter={"user-invocable": False}) + _write_skill_md( + skill_dir, + "knowledge-skill", + body="Knowledge content", + extra_frontmatter={"user-invocable": False}, + ) skill = _make_skill({"skills_path": tmpdir}) skill.setup() @@ -216,9 +242,14 @@ def test_user_invocable_false_skips_tool_keeps_prompt(self) -> None: def test_ignore_invocation_control_registers_everything(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: skill_dir = Path(tmpdir) / "disabled-skill" - _write_skill_md(skill_dir, "disabled-skill", - extra_frontmatter={"disable-model-invocation": True}) - skill = _make_skill({"skills_path": tmpdir, "ignore_invocation_control": True}) + _write_skill_md( + skill_dir, + "disabled-skill", + extra_frontmatter={"disable-model-invocation": True}, + ) + skill = _make_skill( + {"skills_path": tmpdir, "ignore_invocation_control": True} + ) skill.setup() assert skill._skills[0]["_skip_tool"] is False @@ -251,6 +282,7 @@ def test_default_behavior_registers_both(self) -> None: # Shell injection # --------------------------------------------------------------------------- + class TestShellInjection: """Test shell injection pattern handling.""" @@ -285,19 +317,32 @@ def test_enabled_executes_command(self) -> None: assert "hello" in result.response assert "!`" not in result.response - def test_timeout_handling(self) -> None: - skill = _make_skill({"skills_path": "/tmp", "allow_shell_injection": True}) # noqa: S108 + def test_timeout_handling(self, tmp_path: Path) -> None: + skill = _make_skill( + {"skills_path": str(tmp_path), "allow_shell_injection": True} + ) skill._allow_shell_injection = True skill._shell_timeout = 1 - content = "!`sleep 10`" - result = skill._execute_shell_injection(content, Path("/tmp"), timeout=1) # noqa: S108 + # The command must block for longer than the timeout on EVERY platform: + # `sleep` is not a Windows shell builtin, so it exits immediately there + # and the timeout path is never reached. A python -c sleep is portable + # and is the same interpreter already running the suite. + blocking = f'"{sys.executable}" -c "import time; time.sleep(10)"' + content = f"!`{blocking}`" + # cwd must be a directory that exists on this platform: the product + # passes it to subprocess.run(cwd=...), and a nonexistent cwd fails the + # spawn outright ([WinError 267] The directory name is invalid) before + # the timeout can fire, so the assertion would never see a timeout. + result = skill._execute_shell_injection(content, tmp_path, timeout=1) assert "[command timed out:" in result - def test_error_handling(self) -> None: - skill = _make_skill({"skills_path": "/tmp", "allow_shell_injection": True}) # noqa: S108 + def test_error_handling(self, tmp_path: Path) -> None: + skill = _make_skill( + {"skills_path": str(tmp_path), "allow_shell_injection": True} + ) skill._allow_shell_injection = True content = "!`nonexistent_command_xyz_12345`" - result = skill._execute_shell_injection(content, Path("/tmp"), timeout=5) # noqa: S108 + result = skill._execute_shell_injection(content, tmp_path, timeout=5) # The command will produce stderr but still return (non-zero exit code) # subprocess.run doesn't raise on non-zero exit, so result is stdout (empty) # This is expected behavior — command runs but produces no stdout @@ -308,31 +353,36 @@ def test_error_handling(self) -> None: # Variable substitution # --------------------------------------------------------------------------- + class TestVariableSubstitution: """Test ${CLAUDE_SKILL_DIR} and ${CLAUDE_SESSION_ID} substitution.""" - def test_skill_dir_replaced(self) -> None: - skill = _make_skill({"skills_path": "/tmp"}) # noqa: S108 + def test_skill_dir_replaced(self, tmp_path: Path) -> None: + skill = _make_skill({"skills_path": str(tmp_path)}) content = "Path: ${CLAUDE_SKILL_DIR}/file.txt" - result = skill._substitute_variables(content, Path("/opt/skills/my-skill")) - assert result == "Path: /opt/skills/my-skill/file.txt" - - def test_session_id_replaced(self) -> None: - skill = _make_skill({"skills_path": "/tmp"}) # noqa: S108 + # The product substitutes str(skill_dir), so the expectation must be + # built from the same Path rather than a POSIX literal: + # str(Path("/opt/skills/my-skill")) is "\opt\skills\my-skill" on Windows. + skill_dir = Path("/opt/skills/my-skill") + result = skill._substitute_variables(content, skill_dir) + assert result == f"Path: {skill_dir}/file.txt" + + def test_session_id_replaced(self, tmp_path: Path) -> None: + skill = _make_skill({"skills_path": str(tmp_path)}) content = "Session: ${CLAUDE_SESSION_ID}" - result = skill._substitute_variables(content, Path("/tmp"), {"call_id": "abc-123"}) # noqa: S108 + result = skill._substitute_variables(content, tmp_path, {"call_id": "abc-123"}) assert result == "Session: abc-123" - def test_missing_raw_data_graceful(self) -> None: - skill = _make_skill({"skills_path": "/tmp"}) # noqa: S108 + def test_missing_raw_data_graceful(self, tmp_path: Path) -> None: + skill = _make_skill({"skills_path": str(tmp_path)}) content = "Session: ${CLAUDE_SESSION_ID}" - result = skill._substitute_variables(content, Path("/tmp"), None) # noqa: S108 + result = skill._substitute_variables(content, tmp_path, None) assert result == "Session: " - def test_missing_call_id_graceful(self) -> None: - skill = _make_skill({"skills_path": "/tmp"}) # noqa: S108 + def test_missing_call_id_graceful(self, tmp_path: Path) -> None: + skill = _make_skill({"skills_path": str(tmp_path)}) content = "Session: ${CLAUDE_SESSION_ID}" - result = skill._substitute_variables(content, Path("/tmp"), {"other_key": "val"}) # noqa: S108 + result = skill._substitute_variables(content, tmp_path, {"other_key": "val"}) assert result == "Session: " @@ -340,30 +390,31 @@ def test_missing_call_id_graceful(self) -> None: # Fallback argument appending # --------------------------------------------------------------------------- + class TestFallbackArguments: """Test fallback argument appending when body lacks bare $ARGUMENTS.""" - def test_body_with_bare_arguments_no_fallback(self) -> None: - skill = _make_skill({"skills_path": "/tmp"}) # noqa: S108 + def test_body_with_bare_arguments_no_fallback(self, tmp_path: Path) -> None: + skill = _make_skill({"skills_path": str(tmp_path)}) result = skill._substitute_arguments("Use $ARGUMENTS here", "some input") assert result == "Use some input here" assert "ARGUMENTS:" not in result - def test_body_without_arguments_appends_fallback(self) -> None: - skill = _make_skill({"skills_path": "/tmp"}) # noqa: S108 + def test_body_without_arguments_appends_fallback(self, tmp_path: Path) -> None: + skill = _make_skill({"skills_path": str(tmp_path)}) result = skill._substitute_arguments("Do the thing", "some input") assert "Do the thing" in result assert "\n\nARGUMENTS: some input" in result - def test_indexed_form_triggers_fallback(self) -> None: - skill = _make_skill({"skills_path": "/tmp"}) # noqa: S108 + def test_indexed_form_triggers_fallback(self, tmp_path: Path) -> None: + skill = _make_skill({"skills_path": str(tmp_path)}) result = skill._substitute_arguments("Use $ARGUMENTS[0] only", "hello world") # $ARGUMENTS[0] is indexed — bare $ARGUMENTS not present, so fallback appends assert "hello" in result assert "\n\nARGUMENTS: hello world" in result - def test_empty_arguments_no_append(self) -> None: - skill = _make_skill({"skills_path": "/tmp"}) # noqa: S108 + def test_empty_arguments_no_append(self, tmp_path: Path) -> None: + skill = _make_skill({"skills_path": str(tmp_path)}) result = skill._substitute_arguments("Do the thing", "") assert result == "Do the thing" assert "ARGUMENTS:" not in result @@ -373,6 +424,7 @@ def test_empty_arguments_no_append(self) -> None: # File discovery # --------------------------------------------------------------------------- + class TestFileDiscovery: """Test scripts/ and assets/ discovery.""" @@ -446,6 +498,7 @@ def test_hidden_files_skipped(self) -> None: # Prompt section fix (renamed to _get_prompt_sections) # --------------------------------------------------------------------------- + class TestPromptSectionFix: """Test that skip_prompt works correctly via base class.""" @@ -476,6 +529,7 @@ def test_no_skip_prompt_returns_sections(self) -> None: # Unsupported feature warnings # --------------------------------------------------------------------------- + class TestUnsupportedFeatureWarnings: """Test that unsupported frontmatter fields trigger warnings.""" @@ -492,7 +546,9 @@ def test_context_fork_warning(self) -> None: def test_agent_warning(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: skill_dir = Path(tmpdir) / "agent-skill" - _write_skill_md(skill_dir, "agent-skill", extra_frontmatter={"agent": "Explore"}) + _write_skill_md( + skill_dir, "agent-skill", extra_frontmatter={"agent": "Explore"} + ) skill = _make_skill({"skills_path": tmpdir}) with patch("signalwire.skills.claude_skills.skill.logger") as mock_logger: skill.setup() @@ -502,7 +558,11 @@ def test_agent_warning(self) -> None: def test_allowed_tools_warning(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: skill_dir = Path(tmpdir) / "tools-skill" - _write_skill_md(skill_dir, "tools-skill", extra_frontmatter={"allowed-tools": "Read, Grep"}) + _write_skill_md( + skill_dir, + "tools-skill", + extra_frontmatter={"allowed-tools": "Read, Grep"}, + ) skill = _make_skill({"skills_path": tmpdir}) with patch("signalwire.skills.claude_skills.skill.logger") as mock_logger: skill.setup() @@ -512,7 +572,9 @@ def test_allowed_tools_warning(self) -> None: def test_model_warning(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: skill_dir = Path(tmpdir) / "model-skill" - _write_skill_md(skill_dir, "model-skill", extra_frontmatter={"model": "opus"}) + _write_skill_md( + skill_dir, "model-skill", extra_frontmatter={"model": "opus"} + ) skill = _make_skill({"skills_path": tmpdir}) with patch("signalwire.skills.claude_skills.skill.logger") as mock_logger: skill.setup() @@ -522,7 +584,9 @@ def test_model_warning(self) -> None: def test_hooks_warning(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: skill_dir = Path(tmpdir) / "hooks-skill" - _write_skill_md(skill_dir, "hooks-skill", extra_frontmatter={"hooks": "pre-run"}) + _write_skill_md( + skill_dir, "hooks-skill", extra_frontmatter={"hooks": "pre-run"} + ) skill = _make_skill({"skills_path": tmpdir}) with patch("signalwire.skills.claude_skills.skill.logger") as mock_logger: skill.setup() @@ -537,8 +601,11 @@ def test_shell_pattern_warning_when_disabled(self) -> None: with patch("signalwire.skills.claude_skills.skill.logger") as mock_logger: skill.setup() warning_calls = [str(c) for c in mock_logger.warning.call_args_list] - assert any("shell injection pattern" in c and "allow_shell_injection is disabled" in c - for c in warning_calls) + assert any( + "shell injection pattern" in c + and "allow_shell_injection is disabled" in c + for c in warning_calls + ) def test_no_warnings_for_clean_skill(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -548,8 +615,11 @@ def test_no_warnings_for_clean_skill(self) -> None: with patch("signalwire.skills.claude_skills.skill.logger") as mock_logger: skill.setup() warning_calls = [str(c) for c in mock_logger.warning.call_args_list] - unsupported_warnings = [c for c in warning_calls - if "not supported" in c or "shell injection" in c] + unsupported_warnings = [ + c + for c in warning_calls + if "not supported" in c or "shell injection" in c + ] assert len(unsupported_warnings) == 0 @@ -557,21 +627,30 @@ def test_no_warnings_for_clean_skill(self) -> None: # Handler pipeline integration # --------------------------------------------------------------------------- + class TestHandlerPipeline: """Test the full handler processing pipeline.""" def test_full_pipeline_ordering(self) -> None: """Shell injection -> variables -> arguments -> wrapping.""" with tempfile.TemporaryDirectory() as tmpdir: - skill_dir = Path(tmpdir) / "pipeline-skill" + # setup() canonicalizes skills_path via .resolve(), so the dir the + # product reports is the RESOLVED one. On Windows the temp dir is + # handed out in 8.3 short form (C:\Users\RUNNER~1\...) and resolve() + # expands it to the long form (C:\Users\runneradmin\...) — comparing + # an unresolved expectation to resolved output fails. Resolve here + # too, so both sides name the same directory the same way. + skill_dir = (Path(tmpdir).resolve()) / "pipeline-skill" body = "Dir: ${CLAUDE_SKILL_DIR} | Args: $ARGUMENTS" _write_skill_md(skill_dir, "pipeline-skill", body=body) - skill = _make_skill({ - "skills_path": tmpdir, - "response_prefix": "PREFIX", - "response_postfix": "POSTFIX", - }) + skill = _make_skill( + { + "skills_path": tmpdir, + "response_prefix": "PREFIX", + "response_postfix": "POSTFIX", + } + ) skill.setup() skill.register_tools() @@ -591,15 +670,21 @@ def test_full_pipeline_ordering(self) -> None: def test_shell_then_variables_then_arguments(self) -> None: """Verify processing order: shell first, then vars, then args.""" with tempfile.TemporaryDirectory() as tmpdir: - skill_dir = Path(tmpdir) / "order-skill" + # .resolve() to match the product's canonicalized skills_path — see + # test_full_pipeline_ordering for the Windows 8.3 short-path detail. + skill_dir = (Path(tmpdir).resolve()) / "order-skill" # Shell outputs something, then variable and arg substitution happens - body = "Shell: !`echo shellout` | Dir: ${CLAUDE_SKILL_DIR} | Arg: $ARGUMENTS" + body = ( + "Shell: !`echo shellout` | Dir: ${CLAUDE_SKILL_DIR} | Arg: $ARGUMENTS" + ) _write_skill_md(skill_dir, "order-skill", body=body) - skill = _make_skill({ - "skills_path": tmpdir, - "allow_shell_injection": True, - }) + skill = _make_skill( + { + "skills_path": tmpdir, + "allow_shell_injection": True, + } + ) skill.setup() skill.register_tools() @@ -632,13 +717,14 @@ def test_variable_substitution_in_handler(self) -> None: def test_section_loading_with_pipeline(self) -> None: """Test that section files also go through the pipeline.""" with tempfile.TemporaryDirectory() as tmpdir: - skill_dir = Path(tmpdir) / "section-skill" + # .resolve() to match the product's canonicalized skills_path — see + # test_full_pipeline_ordering for the Windows 8.3 short-path detail. + skill_dir = (Path(tmpdir).resolve()) / "section-skill" _write_skill_md(skill_dir, "section-skill", body="Main body") # Create a section file with variable placeholders (skill_dir / "reference.md").write_text( - "Ref dir: ${CLAUDE_SKILL_DIR} | Args: $ARGUMENTS", - encoding="utf-8" + "Ref dir: ${CLAUDE_SKILL_DIR} | Args: $ARGUMENTS", encoding="utf-8" ) skill = _make_skill({"skills_path": tmpdir}) @@ -658,6 +744,7 @@ def test_section_loading_with_pipeline(self) -> None: # Parameter schema # --------------------------------------------------------------------------- + class TestParameterSchema: """Test get_parameter_schema includes new params.""" diff --git a/tests/unit/skills/test_datasphere_serverless_skill.py b/tests/unit/skills/test_datasphere_serverless_skill.py index 8d0a5086..022facb4 100644 --- a/tests/unit/skills/test_datasphere_serverless_skill.py +++ b/tests/unit/skills/test_datasphere_serverless_skill.py @@ -22,7 +22,10 @@ class TestDataSphereServerlessSkillClassAttributes: def _get_skill_class(self) -> "type[DataSphereServerlessSkill]": """Import and return the skill class with mocked dependencies""" - from signalwire.skills.datasphere_serverless.skill import DataSphereServerlessSkill + from signalwire.skills.datasphere_serverless.skill import ( + DataSphereServerlessSkill, + ) + return DataSphereServerlessSkill def test_skill_name(self) -> None: @@ -33,7 +36,10 @@ def test_skill_name(self) -> None: def test_skill_description(self) -> None: """Test SKILL_DESCRIPTION is set correctly""" cls = self._get_skill_class() - assert cls.SKILL_DESCRIPTION == "Search knowledge using SignalWire DataSphere with serverless DataMap execution" + assert ( + cls.SKILL_DESCRIPTION + == "Search knowledge using SignalWire DataSphere with serverless DataMap execution" + ) def test_skill_version(self) -> None: """Test SKILL_VERSION is set correctly""" @@ -60,7 +66,10 @@ class TestDataSphereServerlessSkillParameterSchema: """Test get_parameter_schema class method""" def _get_skill_class(self) -> "type[DataSphereServerlessSkill]": - from signalwire.skills.datasphere_serverless.skill import DataSphereServerlessSkill + from signalwire.skills.datasphere_serverless.skill import ( + DataSphereServerlessSkill, + ) + return DataSphereServerlessSkill def test_schema_includes_base_params(self) -> None: @@ -162,7 +171,12 @@ def test_schema_includes_pos_to_expand(self) -> None: assert "pos_to_expand" in schema assert schema["pos_to_expand"]["type"] == "array" assert schema["pos_to_expand"]["required"] is False - assert schema["pos_to_expand"]["items"]["enum"] == ["NOUN", "VERB", "ADJ", "ADV"] + assert schema["pos_to_expand"]["items"]["enum"] == [ + "NOUN", + "VERB", + "ADJ", + "ADV", + ] def test_schema_includes_max_synonyms(self) -> None: """Test max_synonyms parameter is defined with bounds""" @@ -191,10 +205,19 @@ def test_schema_has_all_expected_params(self) -> None: schema = cls.get_parameter_schema() expected_params = [ - "swaig_fields", "tool_name", # from base class - "space_name", "project_id", "token", "document_id", - "count", "distance", "tags", "language", - "pos_to_expand", "max_synonyms", "no_results_message" + "swaig_fields", + "tool_name", # from base class + "space_name", + "project_id", + "token", + "document_id", + "count", + "distance", + "tags", + "language", + "pos_to_expand", + "max_synonyms", + "no_results_message", ] for param in expected_params: @@ -241,7 +264,7 @@ def test_init_sets_params(self) -> None: skill, _ = _create_skill() assert skill.params["space_name"] == "testspace" assert skill.params["project_id"] == "test-project-id" - assert skill.params["token"] == "test-token-secret" # noqa: S105 + assert skill.params["token"] == "test-token-secret" assert skill.params["document_id"] == "doc-123" def test_init_creates_logger(self) -> None: @@ -265,7 +288,10 @@ def test_init_empty_swaig_fields_by_default(self) -> None: def test_init_with_empty_params(self) -> None: """Test that init works with None params""" - from signalwire.skills.datasphere_serverless.skill import DataSphereServerlessSkill + from signalwire.skills.datasphere_serverless.skill import ( + DataSphereServerlessSkill, + ) + mock_agent = Mock() skill = DataSphereServerlessSkill(mock_agent, None) assert skill.params == {} @@ -310,7 +336,7 @@ def test_setup_stores_required_params_as_attributes(self) -> None: assert skill.space_name == "testspace" assert skill.project_id == "test-project-id" - assert skill.token == "test-token-secret" # noqa: S105 + assert skill.token == "test-token-secret" assert skill.document_id == "doc-123" def test_setup_missing_space_name(self) -> None: @@ -339,7 +365,10 @@ def test_setup_missing_document_id(self) -> None: def test_setup_missing_all_required_params(self) -> None: """Test that setup returns False when all required params are missing""" - from signalwire.skills.datasphere_serverless.skill import DataSphereServerlessSkill + from signalwire.skills.datasphere_serverless.skill import ( + DataSphereServerlessSkill, + ) + mock_agent = Mock() skill = DataSphereServerlessSkill(mock_agent, {}) result = skill.setup() @@ -458,13 +487,19 @@ def test_setup_builds_api_url(self) -> None: """Test that setup builds the correct API URL""" skill, _ = _create_skill() skill.setup() - assert skill.api_url == "https://testspace.signalwire.com/api/datasphere/documents/search" + assert ( + skill.api_url + == "https://testspace.signalwire.com/api/datasphere/documents/search" + ) def test_setup_api_url_with_different_space(self) -> None: """Test API URL with a different space name""" skill, _ = _create_skill(params={"space_name": "mycompany"}) skill.setup() - assert skill.api_url == "https://mycompany.signalwire.com/api/datasphere/documents/search" + assert ( + skill.api_url + == "https://mycompany.signalwire.com/api/datasphere/documents/search" + ) def test_setup_builds_auth_header(self) -> None: """Test that setup builds the correct base64 auth header""" @@ -476,10 +511,7 @@ def test_setup_builds_auth_header(self) -> None: def test_setup_auth_header_encoding(self) -> None: """Test that auth header is proper base64 of project_id:token""" - skill, _ = _create_skill(params={ - "project_id": "proj-abc", - "token": "tok-xyz" - }) + skill, _ = _create_skill(params={"project_id": "proj-abc", "token": "tok-xyz"}) skill.setup() decoded = base64.b64decode(skill.auth_header).decode() @@ -591,7 +623,10 @@ def test_register_tools_webhook_url(self) -> None: swaig_func = mock_agent.register_swaig_function.call_args[0][0] webhook = swaig_func["data_map"]["webhooks"][0] - assert webhook["url"] == "https://testspace.signalwire.com/api/datasphere/documents/search" + assert ( + webhook["url"] + == "https://testspace.signalwire.com/api/datasphere/documents/search" + ) def test_register_tools_webhook_auth_header(self) -> None: """Test that webhook has correct Authorization header""" @@ -707,12 +742,14 @@ def test_register_tools_includes_max_synonyms_when_provided(self) -> None: def test_register_tools_includes_all_optional_params(self) -> None: """Test that all optional params are included when all are provided""" - skill, mock_agent = _create_skill(params={ - "tags": ["doc"], - "language": "en", - "pos_to_expand": ["VERB"], - "max_synonyms": 2 - }) + skill, mock_agent = _create_skill( + params={ + "tags": ["doc"], + "language": "en", + "pos_to_expand": ["VERB"], + "max_synonyms": 2, + } + ) skill.setup() skill.register_tools() @@ -817,7 +854,7 @@ def test_register_tools_merges_swaig_fields(self) -> None: skill.register_tools() swaig_func = mock_agent.register_swaig_function.call_args[0][0] - assert swaig_func["meta_data_token"] == "custom_token" # noqa: S105 + assert swaig_func["meta_data_token"] == "custom_token" assert swaig_func["fillers"] == {"en": ["hmm"]} def test_register_tools_swaig_fields_do_not_overwrite_core_fields(self) -> None: @@ -869,7 +906,11 @@ def test_get_global_data_keys(self) -> None: skill.setup() data = skill.get_global_data() - expected_keys = {"datasphere_serverless_enabled", "document_id", "knowledge_provider"} + expected_keys = { + "datasphere_serverless_enabled", + "document_id", + "knowledge_provider", + } assert set(data.keys()) == expected_keys @@ -918,7 +959,9 @@ def test_get_prompt_sections_bullets_reference_tool_name(self) -> None: skill, _ = _create_skill() skill.setup() section = skill.get_prompt_sections()[0] - tool_name_found = any("search_knowledge" in bullet for bullet in section["bullets"]) + tool_name_found = any( + "search_knowledge" in bullet for bullet in section["bullets"] + ) assert tool_name_found def test_get_prompt_sections_mentions_serverless(self) -> None: @@ -926,7 +969,9 @@ def test_get_prompt_sections_mentions_serverless(self) -> None: skill, _ = _create_skill() skill.setup() section = skill.get_prompt_sections()[0] - serverless_found = any("server" in bullet.lower() for bullet in section["bullets"]) + serverless_found = any( + "server" in bullet.lower() for bullet in section["bullets"] + ) assert serverless_found @@ -937,14 +982,16 @@ def test_setup_with_special_characters_in_space_name(self) -> None: """Test setup with special characters in space name""" skill, _ = _create_skill(params={"space_name": "my-company-123"}) skill.setup() - assert skill.api_url == "https://my-company-123.signalwire.com/api/datasphere/documents/search" + assert ( + skill.api_url + == "https://my-company-123.signalwire.com/api/datasphere/documents/search" + ) def test_setup_with_special_characters_in_credentials(self) -> None: """Test that auth header is correctly encoded with special characters""" - skill, _ = _create_skill(params={ - "project_id": "proj+id/special", - "token": "tok=en/special+chars" - }) + skill, _ = _create_skill( + params={"project_id": "proj+id/special", "token": "tok=en/special+chars"} + ) skill.setup() decoded = base64.b64decode(skill.auth_header).decode() @@ -952,16 +999,12 @@ def test_setup_with_special_characters_in_credentials(self) -> None: def test_multiple_skills_with_different_configs(self) -> None: """Test creating multiple skill instances with different configs""" - skill1, _agent1 = _create_skill(params={ - "tool_name": "search_docs", - "document_id": "doc-1", - "count": 3 - }) - skill2, _agent2 = _create_skill(params={ - "tool_name": "search_faq", - "document_id": "doc-2", - "count": 1 - }) + skill1, _agent1 = _create_skill( + params={"tool_name": "search_docs", "document_id": "doc-1", "count": 3} + ) + skill2, _agent2 = _create_skill( + params={"tool_name": "search_faq", "document_id": "doc-2", "count": 1} + ) skill1.setup() skill2.setup() diff --git a/tests/unit/skills/test_datasphere_skill.py b/tests/unit/skills/test_datasphere_skill.py index 14ff55d6..0b736e47 100644 --- a/tests/unit/skills/test_datasphere_skill.py +++ b/tests/unit/skills/test_datasphere_skill.py @@ -42,6 +42,7 @@ def _make_skill(params: dict[str, Any] | None = None) -> DataSphereSkill: # Class-level attributes # --------------------------------------------------------------------------- + class TestDataSphereSkillClassAttributes: """Verify class-level constants and metadata.""" @@ -49,7 +50,10 @@ def test_skill_name(self) -> None: assert DataSphereSkill.SKILL_NAME == "datasphere" def test_skill_description(self) -> None: - assert DataSphereSkill.SKILL_DESCRIPTION == "Search knowledge using SignalWire DataSphere RAG stack" + assert ( + DataSphereSkill.SKILL_DESCRIPTION + == "Search knowledge using SignalWire DataSphere RAG stack" + ) def test_skill_version(self) -> None: assert DataSphereSkill.SKILL_VERSION == "1.0.0" @@ -68,6 +72,7 @@ def test_supports_multiple_instances(self) -> None: # Initialization # --------------------------------------------------------------------------- + class TestDataSphereSkillInit: """Tests for __init__ (inherited from SkillBase).""" @@ -106,6 +111,7 @@ def test_swaig_fields_default_empty(self) -> None: # get_parameter_schema # --------------------------------------------------------------------------- + class TestGetParameterSchema: """Tests for the class method get_parameter_schema.""" @@ -117,8 +123,15 @@ def test_contains_required_params(self) -> None: def test_contains_optional_params(self) -> None: schema = DataSphereSkill.get_parameter_schema() - for key in ("count", "distance", "tags", "language", "pos_to_expand", - "max_synonyms", "no_results_message"): + for key in ( + "count", + "distance", + "tags", + "language", + "pos_to_expand", + "max_synonyms", + "no_results_message", + ): assert key in schema, f"Missing optional param: {key}" assert schema[key]["required"] is False @@ -164,6 +177,7 @@ def test_pos_to_expand_items_enum(self) -> None: # get_instance_key # --------------------------------------------------------------------------- + class TestGetInstanceKey: """Tests for get_instance_key.""" @@ -180,6 +194,7 @@ def test_custom_tool_name_instance_key(self) -> None: # setup() # --------------------------------------------------------------------------- + class TestSetup: """Tests for the setup method.""" @@ -191,14 +206,17 @@ def test_setup_success_all_required(self, mock_session_cls: MagicMock) -> None: assert result is True assert skill.space_name == "testspace" assert skill.project_id == "test-project-id" - assert skill.token == "test-token" # noqa: S105 + assert skill.token == "test-token" assert skill.document_id == "test-doc-id" @patch("signalwire.skills.datasphere.skill.requests.Session") def test_setup_creates_api_url(self, mock_session_cls: MagicMock) -> None: skill = _make_skill() skill.setup() - assert skill.api_url == "https://testspace.signalwire.com/api/datasphere/documents/search" + assert ( + skill.api_url + == "https://testspace.signalwire.com/api/datasphere/documents/search" + ) @patch("signalwire.skills.datasphere.skill.requests.Session") def test_setup_creates_session(self, mock_session_cls: MagicMock) -> None: @@ -223,16 +241,18 @@ def test_setup_optional_defaults(self, mock_session_cls: MagicMock) -> None: @patch("signalwire.skills.datasphere.skill.requests.Session") def test_setup_custom_optional_values(self, mock_session_cls: MagicMock) -> None: - skill = _make_skill({ - "count": 5, - "distance": 1.5, - "tags": ["faq", "billing"], - "language": "es", - "pos_to_expand": ["NOUN", "VERB"], - "max_synonyms": 3, - "tool_name": "kb_search", - "no_results_message": "Nothing found.", - }) + skill = _make_skill( + { + "count": 5, + "distance": 1.5, + "tags": ["faq", "billing"], + "language": "es", + "pos_to_expand": ["NOUN", "VERB"], + "max_synonyms": 3, + "tool_name": "kb_search", + "no_results_message": "Nothing found.", + } + ) skill.setup() assert skill.count == 5 @@ -287,11 +307,14 @@ def test_setup_logs_error_on_missing_params(self) -> None: # register_tools() # --------------------------------------------------------------------------- + class TestRegisterTools: """Tests for register_tools method.""" @patch("signalwire.skills.datasphere.skill.requests.Session") - def test_register_tools_calls_define_tool(self, mock_session_cls: MagicMock) -> None: + def test_register_tools_calls_define_tool( + self, mock_session_cls: MagicMock + ) -> None: skill = _make_skill() skill.setup() skill.register_tools() @@ -305,7 +328,10 @@ def test_register_tools_default_name(self, mock_session_cls: MagicMock) -> None: skill.register_tools() call_kwargs = skill.agent.define_tool.call_args - assert call_kwargs[1]["name"] == "search_knowledge" or call_kwargs.kwargs.get("name") == "search_knowledge" + assert ( + call_kwargs[1]["name"] == "search_knowledge" + or call_kwargs.kwargs.get("name") == "search_knowledge" + ) @patch("signalwire.skills.datasphere.skill.requests.Session") def test_register_tools_custom_name(self, mock_session_cls: MagicMock) -> None: @@ -319,7 +345,9 @@ def test_register_tools_custom_name(self, mock_session_cls: MagicMock) -> None: assert kw["name"] == "kb_lookup" @patch("signalwire.skills.datasphere.skill.requests.Session") - def test_register_tools_has_query_parameter(self, mock_session_cls: MagicMock) -> None: + def test_register_tools_has_query_parameter( + self, mock_session_cls: MagicMock + ) -> None: skill = _make_skill() skill.setup() skill.register_tools() @@ -329,7 +357,9 @@ def test_register_tools_has_query_parameter(self, mock_session_cls: MagicMock) - assert kw["parameters"]["query"]["type"] == "string" @patch("signalwire.skills.datasphere.skill.requests.Session") - def test_register_tools_handler_is_callable(self, mock_session_cls: MagicMock) -> None: + def test_register_tools_handler_is_callable( + self, mock_session_cls: MagicMock + ) -> None: skill = _make_skill() skill.setup() skill.register_tools() @@ -338,7 +368,9 @@ def test_register_tools_handler_is_callable(self, mock_session_cls: MagicMock) - assert callable(kw["handler"]) @patch("signalwire.skills.datasphere.skill.requests.Session") - def test_register_tools_merges_swaig_fields(self, mock_session_cls: MagicMock) -> None: + def test_register_tools_merges_swaig_fields( + self, mock_session_cls: MagicMock + ) -> None: """swaig_fields from params should be merged into define_tool call.""" params = { "swaig_fields": {"meta_data": {"key": "val"}}, @@ -361,6 +393,7 @@ def test_register_tools_merges_swaig_fields(self, mock_session_cls: MagicMock) - # _search_knowledge_handler() # --------------------------------------------------------------------------- + class TestSearchKnowledgeHandler: """Tests for the _search_knowledge_handler method.""" @@ -489,7 +522,9 @@ def test_timeout_error(self) -> None: def test_http_error(self) -> None: skill, mock_session = self._setup_skill() mock_response = Mock() - mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("403 Forbidden") + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError( + "403 Forbidden" + ) mock_session.post.return_value = mock_response result = skill._search_knowledge_handler({"query": "test"}, {}) @@ -537,12 +572,14 @@ def test_request_payload_excludes_none_optionals(self) -> None: assert "max_synonyms" not in payload def test_request_payload_includes_optional_when_set(self) -> None: - skill, mock_session = self._setup_skill(params={ - "tags": ["faq"], - "language": "en", - "pos_to_expand": ["NOUN"], - "max_synonyms": 5, - }) + skill, mock_session = self._setup_skill( + params={ + "tags": ["faq"], + "language": "en", + "pos_to_expand": ["NOUN"], + "max_synonyms": 5, + } + ) mock_response = Mock() mock_response.json.return_value = {"chunks": []} mock_response.raise_for_status = Mock() @@ -579,7 +616,10 @@ def test_request_url(self) -> None: skill._search_knowledge_handler({"query": "test"}, {}) call_args = mock_session.post.call_args - assert call_args[0][0] == "https://testspace.signalwire.com/api/datasphere/documents/search" + assert ( + call_args[0][0] + == "https://testspace.signalwire.com/api/datasphere/documents/search" + ) def test_request_headers(self) -> None: skill, mock_session = self._setup_skill() @@ -664,6 +704,7 @@ def test_invalid_response_empty_dict(self) -> None: # _format_search_results() # --------------------------------------------------------------------------- + class TestFormatSearchResults: """Tests for the _format_search_results helper.""" @@ -730,6 +771,7 @@ def test_query_appears_in_output(self) -> None: # cleanup() # --------------------------------------------------------------------------- + class TestCleanup: """Tests for the cleanup method.""" @@ -752,6 +794,7 @@ def test_cleanup_no_session_does_not_raise(self) -> None: # get_hints() # --------------------------------------------------------------------------- + class TestGetHints: """Tests for the get_hints method.""" @@ -764,6 +807,7 @@ def test_returns_empty_list(self) -> None: # get_global_data() # --------------------------------------------------------------------------- + class TestGetGlobalData: """Tests for the get_global_data method.""" @@ -788,6 +832,7 @@ def test_reflects_configured_document_id(self, mock_session_cls: MagicMock) -> N # get_prompt_sections() # --------------------------------------------------------------------------- + class TestGetPromptSections: """Tests for the get_prompt_sections method.""" @@ -813,7 +858,9 @@ def test_section_references_tool_name(self, mock_session_cls: MagicMock) -> None assert "search_knowledge" in section["body"] @patch("signalwire.skills.datasphere.skill.requests.Session") - def test_section_references_custom_tool_name(self, mock_session_cls: MagicMock) -> None: + def test_section_references_custom_tool_name( + self, mock_session_cls: MagicMock + ) -> None: skill = _make_skill({"tool_name": "my_kb"}) skill.setup() section = skill.get_prompt_sections()[0] @@ -833,11 +880,14 @@ def test_section_has_bullets(self, mock_session_cls: MagicMock) -> None: # Edge cases and integration-style tests # --------------------------------------------------------------------------- + class TestEdgeCases: """Edge case and integration-style tests.""" @patch("signalwire.skills.datasphere.skill.requests.Session") - def test_setup_then_register_then_handler_flow(self, mock_session_cls: MagicMock) -> None: + def test_setup_then_register_then_handler_flow( + self, mock_session_cls: MagicMock + ) -> None: """Full lifecycle: setup -> register -> handle search.""" skill = _make_skill() assert skill.setup() is True @@ -849,9 +899,7 @@ def test_setup_then_register_then_handler_flow(self, mock_session_cls: MagicMock # Set up mock response mock_response = Mock() - mock_response.json.return_value = { - "chunks": [{"text": "Lifecycle answer"}] - } + mock_response.json.return_value = {"chunks": [{"text": "Lifecycle answer"}]} mock_response.raise_for_status = Mock() skill.session.post.return_value = mock_response # type: ignore[attr-defined] # mock attr @@ -863,7 +911,10 @@ def test_setup_then_register_then_handler_flow(self, mock_session_cls: MagicMock def test_api_url_with_special_space_name(self, mock_session_cls: MagicMock) -> None: skill = _make_skill({"space_name": "my-company-space"}) skill.setup() - assert skill.api_url == "https://my-company-space.signalwire.com/api/datasphere/documents/search" + assert ( + skill.api_url + == "https://my-company-space.signalwire.com/api/datasphere/documents/search" + ) @patch("signalwire.skills.datasphere.skill.requests.Session") def test_handler_strips_query_whitespace(self, mock_session_cls: MagicMock) -> None: @@ -881,7 +932,9 @@ def test_handler_strips_query_whitespace(self, mock_session_cls: MagicMock) -> N assert call_kwargs["json"]["query_string"] == "padded query" @patch("signalwire.skills.datasphere.skill.requests.Session") - def test_no_results_message_format_with_query_placeholder_in_invalid_data_path(self, mock_session_cls: MagicMock) -> None: + def test_no_results_message_format_with_query_placeholder_in_invalid_data_path( + self, mock_session_cls: MagicMock + ) -> None: """When API returns non-dict data and no_results_message has {query} placeholder.""" skill = _make_skill({"no_results_message": "Sorry, '{query}' not found."}) skill.setup() @@ -895,7 +948,9 @@ def test_no_results_message_format_with_query_placeholder_in_invalid_data_path(s assert result.response == "Sorry, 'test topic' not found." @patch("signalwire.skills.datasphere.skill.requests.Session") - def test_setup_returns_true_only_with_all_required(self, mock_session_cls: MagicMock) -> None: + def test_setup_returns_true_only_with_all_required( + self, mock_session_cls: MagicMock + ) -> None: """Verify setup returns True only when all four required params are present.""" required = ["space_name", "project_id", "token", "document_id"] for missing in required: @@ -906,7 +961,9 @@ def test_setup_returns_true_only_with_all_required(self, mock_session_cls: Magic assert skill.setup() is False, f"Should fail when {missing} is empty" @patch("signalwire.skills.datasphere.skill.requests.Session") - def test_multiple_instances_different_tool_names(self, mock_session_cls: MagicMock) -> None: + def test_multiple_instances_different_tool_names( + self, mock_session_cls: MagicMock + ) -> None: """Two instances with different tool_names should have different keys.""" skill_a = _make_skill({"tool_name": "search_faq"}) skill_b = _make_skill({"tool_name": "search_docs"}) @@ -916,7 +973,9 @@ def test_multiple_instances_different_tool_names(self, mock_session_cls: MagicMo assert "search_docs" in skill_b.get_instance_key() @patch("signalwire.skills.datasphere.skill.requests.Session") - def test_response_with_dict_no_chunks_key(self, mock_session_cls: MagicMock) -> None: + def test_response_with_dict_no_chunks_key( + self, mock_session_cls: MagicMock + ) -> None: """API returns a valid dict but without the 'chunks' key.""" skill = _make_skill() skill.setup() diff --git a/tests/unit/skills/test_datetime_skill.py b/tests/unit/skills/test_datetime_skill.py index bf4ee8c2..4830afdf 100644 --- a/tests/unit/skills/test_datetime_skill.py +++ b/tests/unit/skills/test_datetime_skill.py @@ -11,12 +11,12 @@ Unit tests for the DateTime skill module """ -from typing import Any # noqa: E402 -from unittest.mock import Mock, patch, MagicMock # noqa: E402 -from datetime import datetime, timezone # noqa: E402 +from typing import Any +from unittest.mock import Mock, patch, MagicMock +from datetime import datetime, timezone -from signalwire.skills.datetime.skill import DateTimeSkill # noqa: E402 -from signalwire.core.function_result import FunctionResult # noqa: E402 +from signalwire.skills.datetime.skill import DateTimeSkill +from signalwire.core.function_result import FunctionResult def _make_skill(params: dict[str, Any] | None = None) -> DateTimeSkill: @@ -37,6 +37,7 @@ def _make_skill(params: dict[str, Any] | None = None) -> DateTimeSkill: # Class-level attributes # --------------------------------------------------------------------------- + class TestDateTimeSkillClassAttributes: """Verify class-level constants and metadata.""" @@ -44,7 +45,10 @@ def test_skill_name(self) -> None: assert DateTimeSkill.SKILL_NAME == "datetime" def test_skill_description(self) -> None: - assert DateTimeSkill.SKILL_DESCRIPTION == "Get current date, time, and timezone information" + assert ( + DateTimeSkill.SKILL_DESCRIPTION + == "Get current date, time, and timezone information" + ) def test_skill_version(self) -> None: assert DateTimeSkill.SKILL_VERSION == "1.0.0" @@ -63,6 +67,7 @@ def test_supports_multiple_instances_default(self) -> None: # Initialization # --------------------------------------------------------------------------- + class TestDateTimeSkillInit: """Tests for __init__ (inherited from SkillBase).""" @@ -91,6 +96,7 @@ def test_swaig_fields_extracted_from_params(self) -> None: # setup() # --------------------------------------------------------------------------- + class TestDateTimeSkillSetup: """Tests for the setup method.""" @@ -101,14 +107,14 @@ def test_setup_returns_true(self) -> None: def test_setup_calls_validate_packages(self) -> None: skill = _make_skill() - with patch.object(skill, 'validate_packages', return_value=True) as mock_vp: + with patch.object(skill, "validate_packages", return_value=True) as mock_vp: result = skill.setup() mock_vp.assert_called_once() assert result is True def test_setup_returns_false_when_packages_missing(self) -> None: skill = _make_skill() - with patch.object(skill, 'validate_packages', return_value=False): + with patch.object(skill, "validate_packages", return_value=False): result = skill.setup() assert result is False @@ -117,6 +123,7 @@ def test_setup_returns_false_when_packages_missing(self) -> None: # register_tools() # --------------------------------------------------------------------------- + class TestDateTimeSkillRegisterTools: """Tests for tool registration.""" @@ -148,7 +155,9 @@ def test_register_tools_passes_handlers(self) -> None: assert callable(call.kwargs["handler"]) def test_register_tools_merges_swaig_fields(self) -> None: - skill = _make_skill(params={"swaig_fields": {"web_hook_url": "http://example.com"}}) + skill = _make_skill( + params={"swaig_fields": {"web_hook_url": "http://example.com"}} + ) skill.register_tools() calls = skill.agent.define_tool.call_args_list for call in calls: @@ -159,6 +168,7 @@ def test_register_tools_merges_swaig_fields(self) -> None: # _get_time_handler() # --------------------------------------------------------------------------- + class TestGetTimeHandler: """Tests for the _get_time_handler method.""" @@ -217,6 +227,7 @@ def test_time_format(self, mock_datetime: MagicMock) -> None: # _get_date_handler() # --------------------------------------------------------------------------- + class TestGetDateHandler: """Tests for the _get_date_handler method.""" @@ -267,6 +278,7 @@ def test_date_format(self, mock_datetime: MagicMock) -> None: # get_hints() # --------------------------------------------------------------------------- + class TestGetHints: """Tests for the get_hints method.""" @@ -285,6 +297,7 @@ def test_returns_list_type(self) -> None: # get_prompt_sections() # --------------------------------------------------------------------------- + class TestGetPromptSections: """Tests for the get_prompt_sections method.""" @@ -320,6 +333,7 @@ def test_section_has_bullets(self) -> None: # get_parameter_schema() # --------------------------------------------------------------------------- + class TestGetParameterSchema: """Tests for the get_parameter_schema classmethod.""" diff --git a/tests/unit/skills/test_info_gatherer_skill.py b/tests/unit/skills/test_info_gatherer_skill.py index 84db1571..8261d74b 100644 --- a/tests/unit/skills/test_info_gatherer_skill.py +++ b/tests/unit/skills/test_info_gatherer_skill.py @@ -47,6 +47,7 @@ def _setup_skill(params: dict[str, Any] | None = None) -> InfoGathererSkill: # Class-Level Metadata # =========================================================================== + class TestInfoGathererSkillMetadata: def test_skill_name(self) -> None: assert InfoGathererSkill.SKILL_NAME == "info_gatherer" @@ -68,6 +69,7 @@ def test_no_required_env_vars(self) -> None: # Parameter Schema # =========================================================================== + class TestParameterSchema: def test_schema_has_questions(self) -> None: schema = InfoGathererSkill.get_parameter_schema() @@ -92,6 +94,7 @@ def test_schema_has_tool_name(self) -> None: # Setup & Validation # =========================================================================== + class TestSetup: def test_setup_success(self) -> None: skill = _make_skill({"questions": SAMPLE_QUESTIONS}) @@ -126,6 +129,7 @@ def test_setup_question_not_dict(self) -> None: # Instance Key # =========================================================================== + class TestInstanceKey: def test_instance_key_without_prefix(self) -> None: skill = _make_skill({"questions": SAMPLE_QUESTIONS}) @@ -142,6 +146,7 @@ def test_instance_key_with_prefix(self) -> None: # Tool Name Derivation # =========================================================================== + class TestToolNames: def test_tool_names_without_prefix(self) -> None: skill = _setup_skill({"questions": SAMPLE_QUESTIONS}) @@ -165,6 +170,7 @@ def test_define_tool_called_with_correct_names(self) -> None: # Namespace Helpers (SkillBase) # =========================================================================== + class TestNamespaceHelpers: def test_namespace_with_prefix(self) -> None: skill = _make_skill({"questions": SAMPLE_QUESTIONS, "prefix": "intake"}) @@ -181,7 +187,10 @@ def test_get_skill_data_present(self) -> None: skill.setup() raw_data = { "global_data": { - "skill:intake": {"question_index": 2, "answers": [{"key_name": "x", "answer": "y"}]} + "skill:intake": { + "question_index": 2, + "answers": [{"key_name": "x", "answer": "y"}], + } } } data = skill.get_skill_data(raw_data) @@ -222,6 +231,7 @@ def test_update_skill_data(self) -> None: # Global Data (initial state) # =========================================================================== + class TestGlobalData: def test_global_data_structure(self) -> None: skill = _make_skill({"questions": SAMPLE_QUESTIONS, "prefix": "intake"}) @@ -244,6 +254,7 @@ def test_global_data_without_prefix(self) -> None: # Prompt Sections # =========================================================================== + class TestPromptSections: def test_prompt_sections_returned(self) -> None: skill = _setup_skill({"questions": SAMPLE_QUESTIONS, "prefix": "intake"}) @@ -262,6 +273,7 @@ def test_prompt_sections_no_prefix(self) -> None: # start_questions handler # =========================================================================== + class TestStartQuestions: def _raw_data( self, prefix: str, questions: list[dict[str, Any]], index: int = 0 @@ -315,6 +327,7 @@ def test_missing_skill_data(self) -> None: # submit_answer handler # =========================================================================== + class TestSubmitAnswer: def _raw_data( self, @@ -360,20 +373,27 @@ def test_submit_stores_answer_in_global_data(self) -> None: def test_submit_last_answer_returns_completion(self) -> None: skill = _setup_skill({"questions": SAMPLE_QUESTIONS, "prefix": "intake"}) - raw = self._raw_data("intake", SAMPLE_QUESTIONS, index=2, answers=[ - {"key_name": "full_name", "answer": "John"}, - {"key_name": "email", "answer": "john@test.com"}, - ]) + raw = self._raw_data( + "intake", + SAMPLE_QUESTIONS, + index=2, + answers=[ + {"key_name": "full_name", "answer": "John"}, + {"key_name": "email", "answer": "john@test.com"}, + ], + ) result = skill._handle_submit_answer({"answer": "Need help"}, raw) d = result.to_dict() assert "All questions have been answered" in d["response"] def test_submit_custom_completion_message(self) -> None: - skill = _setup_skill({ - "questions": [{"key_name": "name", "question_text": "Name?"}], - "prefix": "quick", - "completion_message": "Done! Thanks for answering.", - }) + skill = _setup_skill( + { + "questions": [{"key_name": "name", "question_text": "Name?"}], + "prefix": "quick", + "completion_message": "Done! Thanks for answering.", + } + ) raw = { "global_data": { "skill:quick": { @@ -405,23 +425,28 @@ def test_confirm_flag_propagated(self) -> None: # Multiple Instance Isolation # =========================================================================== + class TestMultipleInstances: def test_two_instances_have_different_namespaces(self) -> None: skill_a = _setup_skill({"questions": SAMPLE_QUESTIONS, "prefix": "intake"}) - skill_b = _setup_skill({ - "questions": [{"key_name": "allergy", "question_text": "Allergies?"}], - "prefix": "medical", - }) + skill_b = _setup_skill( + { + "questions": [{"key_name": "allergy", "question_text": "Allergies?"}], + "prefix": "medical", + } + ) assert skill_a._get_skill_namespace() != skill_b._get_skill_namespace() assert skill_a._get_skill_namespace() == "skill:intake" assert skill_b._get_skill_namespace() == "skill:medical" def test_two_instances_have_different_tool_names(self) -> None: skill_a = _setup_skill({"questions": SAMPLE_QUESTIONS, "prefix": "intake"}) - skill_b = _setup_skill({ - "questions": [{"key_name": "allergy", "question_text": "Allergies?"}], - "prefix": "medical", - }) + skill_b = _setup_skill( + { + "questions": [{"key_name": "allergy", "question_text": "Allergies?"}], + "prefix": "medical", + } + ) assert skill_a.start_tool_name == "intake_start_questions" assert skill_b.start_tool_name == "medical_start_questions" assert skill_a.submit_tool_name == "intake_submit_answer" @@ -429,21 +454,28 @@ def test_two_instances_have_different_tool_names(self) -> None: def test_two_instances_have_different_instance_keys(self) -> None: skill_a = _setup_skill({"questions": SAMPLE_QUESTIONS, "prefix": "intake"}) - skill_b = _setup_skill({ - "questions": [{"key_name": "allergy", "question_text": "Allergies?"}], - "prefix": "medical", - }) + skill_b = _setup_skill( + { + "questions": [{"key_name": "allergy", "question_text": "Allergies?"}], + "prefix": "medical", + } + ) assert skill_a.get_instance_key() != skill_b.get_instance_key() def test_two_instances_read_isolated_state(self) -> None: skill_a = _setup_skill({"questions": SAMPLE_QUESTIONS, "prefix": "intake"}) - skill_b = _setup_skill({ - "questions": [{"key_name": "allergy", "question_text": "Allergies?"}], - "prefix": "medical", - }) + skill_b = _setup_skill( + { + "questions": [{"key_name": "allergy", "question_text": "Allergies?"}], + "prefix": "medical", + } + ) raw = { "global_data": { - "skill:intake": {"question_index": 1, "answers": [{"key_name": "name", "answer": "John"}]}, + "skill:intake": { + "question_index": 1, + "answers": [{"key_name": "name", "answer": "John"}], + }, "skill:medical": {"question_index": 0, "answers": []}, } } @@ -454,10 +486,12 @@ def test_two_instances_read_isolated_state(self) -> None: def test_two_instances_global_data_isolated(self) -> None: skill_a = _setup_skill({"questions": SAMPLE_QUESTIONS, "prefix": "intake"}) - skill_b = _setup_skill({ - "questions": [{"key_name": "allergy", "question_text": "Allergies?"}], - "prefix": "medical", - }) + skill_b = _setup_skill( + { + "questions": [{"key_name": "allergy", "question_text": "Allergies?"}], + "prefix": "medical", + } + ) gd_a = skill_a.get_global_data() gd_b = skill_b.get_global_data() # They should have different namespace keys @@ -470,10 +504,13 @@ def test_two_instances_global_data_isolated(self) -> None: # Question instruction generation # =========================================================================== + class TestQuestionInstruction: def test_first_question_format(self) -> None: text = InfoGathererSkill._generate_question_instruction( - "What is your name?", needs_confirmation=False, is_first_question=True, + "What is your name?", + needs_confirmation=False, + is_first_question=True, ) assert "Ask each question one at a time" in text assert "What is your name?" in text @@ -481,33 +518,43 @@ def test_first_question_format(self) -> None: def test_subsequent_question_format(self) -> None: text = InfoGathererSkill._generate_question_instruction( - "What is your email?", needs_confirmation=False, is_first_question=False, + "What is your email?", + needs_confirmation=False, + is_first_question=False, ) assert "Previous answer saved" in text assert "What is your email?" in text def test_confirmation_required(self) -> None: text = InfoGathererSkill._generate_question_instruction( - "SSN?", needs_confirmation=True, is_first_question=True, + "SSN?", + needs_confirmation=True, + is_first_question=True, ) assert "Read the answer back" in text def test_no_confirmation(self) -> None: text = InfoGathererSkill._generate_question_instruction( - "Color?", needs_confirmation=False, is_first_question=True, + "Color?", + needs_confirmation=False, + is_first_question=True, ) assert "Read the answer back" not in text def test_prompt_add_included(self) -> None: text = InfoGathererSkill._generate_question_instruction( - "DOB?", needs_confirmation=False, is_first_question=True, + "DOB?", + needs_confirmation=False, + is_first_question=True, prompt_add="Format in YYYY-MM-DD", ) assert "Note: Format in YYYY-MM-DD" in text def test_prompt_add_empty(self) -> None: text = InfoGathererSkill._generate_question_instruction( - "DOB?", needs_confirmation=False, is_first_question=True, + "DOB?", + needs_confirmation=False, + is_first_question=True, prompt_add="", ) assert "Note:" not in text diff --git a/tests/unit/skills/test_joke_skill.py b/tests/unit/skills/test_joke_skill.py index 1b83d5cf..467c2f9b 100644 --- a/tests/unit/skills/test_joke_skill.py +++ b/tests/unit/skills/test_joke_skill.py @@ -35,6 +35,7 @@ def _make_skill(params: dict[str, Any] | None = None) -> JokeSkill: # Class-level attributes # --------------------------------------------------------------------------- + class TestJokeSkillClassAttributes: """Verify class-level constants and metadata.""" @@ -61,6 +62,7 @@ def test_supports_multiple_instances(self) -> None: # Initialization # --------------------------------------------------------------------------- + class TestJokeSkillInit: """Tests for __init__ (inherited from SkillBase).""" @@ -99,6 +101,7 @@ def test_swaig_fields_default_empty(self) -> None: # get_parameter_schema # --------------------------------------------------------------------------- + class TestGetParameterSchema: """Tests for the class method get_parameter_schema.""" @@ -133,6 +136,7 @@ def test_includes_base_class_swaig_fields(self) -> None: # setup() # --------------------------------------------------------------------------- + class TestSetup: """Tests for the setup method.""" @@ -184,6 +188,7 @@ def test_setup_logs_error_on_missing_api_key(self) -> None: # register_tools() # --------------------------------------------------------------------------- + class TestRegisterTools: """Tests for register_tools method.""" @@ -322,6 +327,7 @@ def test_register_tools_has_fallback_output(self) -> None: # get_hints() # --------------------------------------------------------------------------- + class TestGetHints: """Tests for the get_hints method.""" @@ -338,6 +344,7 @@ def test_returns_list_type(self) -> None: # get_global_data() # --------------------------------------------------------------------------- + class TestGetGlobalData: """Tests for the get_global_data method.""" @@ -358,6 +365,7 @@ def test_joke_skill_enabled(self) -> None: # get_prompt_sections() # --------------------------------------------------------------------------- + class TestGetPromptSections: """Tests for the get_prompt_sections method.""" @@ -405,6 +413,7 @@ def test_section_references_custom_tool_name(self) -> None: # get_instance_key() # --------------------------------------------------------------------------- + class TestGetInstanceKey: """Tests for get_instance_key (single instance skill).""" @@ -417,6 +426,7 @@ def test_instance_key_is_skill_name(self) -> None: # Edge cases and integration-style tests # --------------------------------------------------------------------------- + class TestEdgeCases: """Edge case and integration-style tests.""" @@ -445,4 +455,7 @@ def test_fallback_output_contains_sorry(self) -> None: fallback = swaig_func["data_map"]["output"] # The fallback output should have a response field assert "response" in fallback - assert "sorry" in fallback["response"].lower() or "problem" in fallback["response"].lower() + assert ( + "sorry" in fallback["response"].lower() + or "problem" in fallback["response"].lower() + ) diff --git a/tests/unit/skills/test_list_skills_regression.py b/tests/unit/skills/test_list_skills_regression.py index 44a18cc8..87129213 100644 --- a/tests/unit/skills/test_list_skills_regression.py +++ b/tests/unit/skills/test_list_skills_regression.py @@ -8,6 +8,7 @@ Both must now return the real skill inventory (they delegate to the registry's working ``list_skills()`` scan). These tests pin the fix so it can't regress. """ + import signalwire from signalwire.skills.registry import skill_registry @@ -15,10 +16,14 @@ def test_top_level_list_skills_returns_real_inventory() -> None: skills = signalwire.list_skills() assert isinstance(skills, list) - assert len(skills) > 0, "list_skills() must return the skill inventory, not raise/empty" + assert len(skills) > 0, ( + "list_skills() must return the skill inventory, not raise/empty" + ) names = {s["name"] for s in skills} # core built-in skills must be discoverable - assert {"datetime", "math"} <= names, f"expected built-ins missing from {sorted(names)}" + assert {"datetime", "math"} <= names, ( + f"expected built-ins missing from {sorted(names)}" + ) for s in skills: assert s["name"], "each skill needs a name" assert s["description"], "each skill needs a description" @@ -27,7 +32,11 @@ def test_top_level_list_skills_returns_real_inventory() -> None: def test_discover_skills_returns_inventory_not_none() -> None: discovered = skill_registry.discover_skills() - assert discovered is not None, "discover_skills() must not be a no-op returning None" + assert discovered is not None, ( + "discover_skills() must not be a no-op returning None" + ) assert isinstance(discovered, list) and len(discovered) > 0 # discover_skills mirrors the registry's list_skills scan exactly - assert {s["name"] for s in discovered} == {s["name"] for s in skill_registry.list_skills()} + assert {s["name"] for s in discovered} == { + s["name"] for s in skill_registry.list_skills() + } diff --git a/tests/unit/skills/test_math_skill.py b/tests/unit/skills/test_math_skill.py index bf6fc40f..84da3b06 100644 --- a/tests/unit/skills/test_math_skill.py +++ b/tests/unit/skills/test_math_skill.py @@ -11,11 +11,11 @@ Unit tests for the Math skill module """ -from typing import Any # noqa: E402 -from unittest.mock import Mock # noqa: E402 +from typing import Any +from unittest.mock import Mock -from signalwire.skills.math.skill import MathSkill # noqa: E402 -from signalwire.core.function_result import FunctionResult # noqa: E402 +from signalwire.skills.math.skill import MathSkill +from signalwire.core.function_result import FunctionResult def _make_skill(params: dict[str, Any] | None = None) -> MathSkill: @@ -36,6 +36,7 @@ def _make_skill(params: dict[str, Any] | None = None) -> MathSkill: # Class-level attributes # --------------------------------------------------------------------------- + class TestMathSkillClassAttributes: """Verify class-level constants and metadata.""" @@ -59,6 +60,7 @@ def test_required_env_vars(self) -> None: # Setup # --------------------------------------------------------------------------- + class TestMathSkillSetup: """Tests for the setup method.""" @@ -71,6 +73,7 @@ def test_setup_returns_true(self) -> None: # register_tools # --------------------------------------------------------------------------- + class TestMathSkillRegisterTools: """Tests for register_tools.""" @@ -92,7 +95,10 @@ def test_register_tools_has_description(self) -> None: call_kwargs = skill.agent.define_tool.call_args kwargs = call_kwargs.kwargs if call_kwargs.kwargs else call_kwargs[1] assert "description" in kwargs - assert "mathematical" in kwargs["description"].lower() or "calculation" in kwargs["description"].lower() + assert ( + "mathematical" in kwargs["description"].lower() + or "calculation" in kwargs["description"].lower() + ) def test_register_tools_has_expression_parameter(self) -> None: skill = _make_skill() @@ -115,6 +121,7 @@ def test_register_tools_has_handler(self) -> None: # _calculate_handler - valid expressions # --------------------------------------------------------------------------- + class TestCalculateHandlerValid: """Tests for _calculate_handler with valid expressions.""" @@ -169,6 +176,7 @@ def test_float_numbers(self) -> None: # _calculate_handler - empty / missing expression # --------------------------------------------------------------------------- + class TestCalculateHandlerEmpty: """Tests for _calculate_handler with empty or missing expressions.""" @@ -195,6 +203,7 @@ def test_missing_expression_key(self) -> None: # _calculate_handler - unsafe characters # --------------------------------------------------------------------------- + class TestCalculateHandlerUnsafe: """Tests for _calculate_handler with unsafe / disallowed input.""" @@ -227,6 +236,7 @@ def test_semicolon_rejected(self) -> None: # _calculate_handler - division by zero # --------------------------------------------------------------------------- + class TestCalculateHandlerDivisionByZero: """Tests for _calculate_handler with division by zero.""" @@ -247,6 +257,7 @@ def test_modulo_by_zero(self) -> None: # _calculate_handler - invalid expression (syntax errors) # --------------------------------------------------------------------------- + class TestCalculateHandlerInvalidExpression: """Tests for _calculate_handler with syntactically invalid expressions.""" @@ -267,6 +278,7 @@ def test_unmatched_parens(self) -> None: # get_hints # --------------------------------------------------------------------------- + class TestMathSkillGetHints: """Tests for get_hints.""" @@ -284,6 +296,7 @@ def test_returns_empty_list(self) -> None: # get_prompt_sections # --------------------------------------------------------------------------- + class TestMathSkillGetPromptSections: """Tests for get_prompt_sections.""" @@ -321,6 +334,7 @@ def test_section_has_bullets(self) -> None: # get_parameter_schema # --------------------------------------------------------------------------- + class TestMathSkillGetParameterSchema: """Tests for get_parameter_schema.""" diff --git a/tests/unit/skills/test_mcp_gateway_skill.py b/tests/unit/skills/test_mcp_gateway_skill.py index 98661e46..496ef71d 100644 --- a/tests/unit/skills/test_mcp_gateway_skill.py +++ b/tests/unit/skills/test_mcp_gateway_skill.py @@ -71,6 +71,7 @@ def _make_skill( # Class-level attributes and parameter schema # --------------------------------------------------------------------------- + class TestMCPGatewaySkillClassAttributes: """Test class-level attributes and metadata.""" @@ -80,7 +81,10 @@ def test_skill_name(self) -> None: def test_skill_description(self) -> None: """SKILL_DESCRIPTION should be set.""" - assert MCPGatewaySkill.SKILL_DESCRIPTION == "Bridge MCP servers with SWAIG functions" + assert ( + MCPGatewaySkill.SKILL_DESCRIPTION + == "Bridge MCP servers with SWAIG functions" + ) def test_skill_version(self) -> None: """SKILL_VERSION should be '1.0.0'.""" @@ -138,6 +142,7 @@ def test_schema_inherits_swaig_fields(self) -> None: # Initialization # --------------------------------------------------------------------------- + class TestSkillInitialization: """Test MCPGatewaySkill __init__ via SkillBase.""" @@ -164,12 +169,15 @@ def test_init_extracts_swaig_fields(self) -> None: # setup() method # --------------------------------------------------------------------------- + class TestSetup: """Test the setup() method.""" @patch("signalwire.utils.url_validator.validate_url", return_value=True) @patch("signalwire.skills.mcp_gateway.skill.requests.get") - def test_setup_success_with_basic_auth(self, mock_get: Mock, mock_validate: Mock) -> None: + def test_setup_success_with_basic_auth( + self, mock_get: Mock, mock_validate: Mock + ) -> None: """setup() should succeed when basic auth params are provided and health check passes.""" mock_response = Mock() mock_response.raise_for_status = Mock() @@ -186,7 +194,9 @@ def test_setup_success_with_basic_auth(self, mock_get: Mock, mock_validate: Mock @patch("signalwire.utils.url_validator.validate_url", return_value=True) @patch("signalwire.skills.mcp_gateway.skill.requests.get") - def test_setup_success_with_token_auth(self, mock_get: Mock, mock_validate: Mock) -> None: + def test_setup_success_with_token_auth( + self, mock_get: Mock, mock_validate: Mock + ) -> None: """setup() should succeed with auth_token and gateway_url.""" mock_response = Mock() mock_response.raise_for_status = Mock() @@ -199,7 +209,7 @@ def test_setup_success_with_token_auth(self, mock_get: Mock, mock_validate: Mock result = skill.setup() assert result is True - assert skill.auth_token == "mytoken" # noqa: S105 + assert skill.auth_token == "mytoken" assert skill.auth is None # trailing slash should be stripped assert skill.gateway_url == "https://gw.test" @@ -217,7 +227,11 @@ def test_setup_fails_missing_gateway_url_with_token(self, mock_get: Mock) -> Non def test_setup_fails_missing_basic_auth_params(self) -> None: """setup() should fail if no auth_token and basic auth params are missing.""" skill, _ = _make_skill( - params={"gateway_url": "https://gw.test", "auth_user": "", "auth_password": ""}, + params={ + "gateway_url": "https://gw.test", + "auth_user": "", + "auth_password": "", + }, skip_setup=False, ) result = skill.setup() @@ -234,7 +248,9 @@ def test_setup_fails_missing_gateway_url_basic_auth(self) -> None: @patch("signalwire.utils.url_validator.validate_url", return_value=True) @patch("signalwire.skills.mcp_gateway.skill.requests.get") - def test_setup_fails_on_health_check_error(self, mock_get: Mock, mock_validate: Mock) -> None: + def test_setup_fails_on_health_check_error( + self, mock_get: Mock, mock_validate: Mock + ) -> None: """setup() should return False when the health check raises an exception.""" mock_get.side_effect = ConnectionError("unreachable") @@ -245,7 +261,9 @@ def test_setup_fails_on_health_check_error(self, mock_get: Mock, mock_validate: @patch("signalwire.utils.url_validator.validate_url", return_value=True) @patch("signalwire.skills.mcp_gateway.skill.requests.get") - def test_setup_fails_on_health_check_http_error(self, mock_get: Mock, mock_validate: Mock) -> None: + def test_setup_fails_on_health_check_http_error( + self, mock_get: Mock, mock_validate: Mock + ) -> None: """setup() should return False when health check returns non-200.""" mock_response = Mock() mock_response.raise_for_status.side_effect = Exception("500 Server Error") @@ -258,7 +276,9 @@ def test_setup_fails_on_health_check_http_error(self, mock_get: Mock, mock_valid @patch("signalwire.utils.url_validator.validate_url", return_value=True) @patch("signalwire.skills.mcp_gateway.skill.requests.get") - def test_setup_stores_configuration_defaults(self, mock_get: Mock, mock_validate: Mock) -> None: + def test_setup_stores_configuration_defaults( + self, mock_get: Mock, mock_validate: Mock + ) -> None: """setup() should store default values for optional params.""" mock_response = Mock() mock_response.raise_for_status = Mock() @@ -277,7 +297,9 @@ def test_setup_stores_configuration_defaults(self, mock_get: Mock, mock_validate @patch("signalwire.utils.url_validator.validate_url", return_value=True) @patch("signalwire.skills.mcp_gateway.skill.requests.get") - def test_setup_stores_custom_configuration(self, mock_get: Mock, mock_validate: Mock) -> None: + def test_setup_stores_custom_configuration( + self, mock_get: Mock, mock_validate: Mock + ) -> None: """setup() should store custom values for optional params.""" mock_response = Mock() mock_response.raise_for_status = Mock() @@ -328,6 +350,7 @@ def test_setup_health_check_url(self, mock_get: Mock, mock_validate: Mock) -> No # _make_request # --------------------------------------------------------------------------- + class TestMakeRequest: """Test the _make_request helper.""" @@ -395,13 +418,16 @@ def test_make_request_preserves_existing_headers(self, mock_request: Mock) -> No # register_tools # --------------------------------------------------------------------------- + class TestRegisterTools: """Test the register_tools() method.""" @patch("signalwire.skills.mcp_gateway.skill.requests.request") - def test_register_tools_fetches_all_services_when_none_specified(self, mock_request: Mock) -> None: + def test_register_tools_fetches_all_services_when_none_specified( + self, mock_request: Mock + ) -> None: """When services list is empty, register_tools should query /services.""" - skill, agent = _make_skill(params={"services": []}) + skill, _agent = _make_skill(params={"services": []}) skill.services = [] services_response = Mock() @@ -521,7 +547,9 @@ def test_register_tools_registers_hangup_hook(self, mock_request: Mock) -> None: assert hangup_call.kwargs.get("is_hangup_hook") is True @patch("signalwire.skills.mcp_gateway.skill.requests.request") - def test_register_tools_skips_service_without_name(self, mock_request: Mock) -> None: + def test_register_tools_skips_service_without_name( + self, mock_request: Mock + ) -> None: """Services without a 'name' key should be skipped.""" skill, agent = _make_skill() skill.services = [{"tools": "*"}] # no 'name' key @@ -530,7 +558,8 @@ def test_register_tools_skips_service_without_name(self, mock_request: Mock) -> # Only the hangup hook should be registered calls_with_hangup = [ - c for c in agent.define_tool.call_args_list + c + for c in agent.define_tool.call_args_list if c.kwargs.get("name") == "_mcp_gateway_hangup" ] assert len(calls_with_hangup) == 1 @@ -538,7 +567,9 @@ def test_register_tools_skips_service_without_name(self, mock_request: Mock) -> assert agent.define_tool.call_count == 1 @patch("signalwire.skills.mcp_gateway.skill.requests.request") - def test_register_tools_handles_service_list_error(self, mock_request: Mock) -> None: + def test_register_tools_handles_service_list_error( + self, mock_request: Mock + ) -> None: """register_tools should log error when fetching service list fails.""" skill, _agent = _make_skill() skill.services = [] @@ -565,6 +596,7 @@ def test_register_tools_handles_tools_fetch_error(self, mock_request: Mock) -> N # _register_mcp_tool # --------------------------------------------------------------------------- + class TestRegisterMCPTool: """Test _register_mcp_tool method.""" @@ -613,7 +645,11 @@ def test_register_mcp_tool_converts_schema_properties(self) -> None: "properties": { "name": {"type": "string", "description": "Item name"}, "count": {"type": "integer", "description": "Count", "default": 1}, - "kind": {"type": "string", "description": "Kind", "enum": ["a", "b"]}, + "kind": { + "type": "string", + "description": "Kind", + "enum": ["a", "b"], + }, }, "required": ["name"], }, @@ -692,9 +728,13 @@ def test_register_mcp_tool_handler_calls_call_mcp_tool(self) -> None: call_kwargs = agent.define_tool.call_args.kwargs handler = call_kwargs["handler"] - with patch.object(skill, "_call_mcp_tool", return_value=FunctionResult("ok")) as mock_call: + with patch.object( + skill, "_call_mcp_tool", return_value=FunctionResult("ok") + ) as mock_call: handler({"text": "hi"}, {"call_id": "123"}) - mock_call.assert_called_once_with("svc", "echo", {"text": "hi"}, {"call_id": "123"}) + mock_call.assert_called_once_with( + "svc", "echo", {"text": "hi"}, {"call_id": "123"} + ) def test_register_mcp_tool_empty_input_schema(self) -> None: """Tools with no properties should register with empty parameters.""" @@ -721,6 +761,7 @@ def test_register_mcp_tool_missing_description(self) -> None: # _call_mcp_tool # --------------------------------------------------------------------------- + class TestCallMCPTool: """Test the _call_mcp_tool method.""" @@ -820,7 +861,9 @@ def test_request_contains_metadata(self, mock_request: Mock) -> None: raw_data = {"call_id": "c1", "timestamp": "ts1"} skill._call_mcp_tool("svc", "search", {"q": "test"}, raw_data) - request_body = mock_request.call_args.kwargs.get("json") or mock_request.call_args[1].get("json") + request_body = mock_request.call_args.kwargs.get( + "json" + ) or mock_request.call_args[1].get("json") assert request_body["tool"] == "search" assert request_body["arguments"] == {"q": "test"} assert request_body["timeout"] == 300 @@ -872,7 +915,9 @@ def test_handles_non_json_error_response(self, mock_request: Mock) -> None: error_response = Mock() error_response.status_code = 400 - error_response.json.side_effect = real_requests.exceptions.JSONDecodeError("", "", 0) + error_response.json.side_effect = real_requests.exceptions.JSONDecodeError( + "", "", 0 + ) error_response.text = "Bad Request: invalid payload" mock_request.return_value = error_response @@ -945,6 +990,7 @@ def test_posts_to_correct_url(self, mock_request: Mock) -> None: # _hangup_handler # --------------------------------------------------------------------------- + class TestHangupHandler: """Test the _hangup_handler method.""" @@ -1024,6 +1070,7 @@ def test_hangup_handler_exception(self, mock_request: Mock) -> None: # get_hints # --------------------------------------------------------------------------- + class TestGetHints: """Test the get_hints method.""" @@ -1070,6 +1117,7 @@ def test_hints_handles_dict_without_name(self) -> None: # get_global_data # --------------------------------------------------------------------------- + class TestGetGlobalData: """Test the get_global_data method.""" @@ -1106,6 +1154,7 @@ def test_global_data_empty_services(self) -> None: # get_prompt_sections # --------------------------------------------------------------------------- + class TestGetPromptSections: """Test the get_prompt_sections method.""" @@ -1169,7 +1218,9 @@ def test_prompt_sections_multiple_services(self) -> None: sections = skill.get_prompt_sections() assert len(sections) == 1 - available_line = [b for b in sections[0]["bullets"] if "Available services" in b] + available_line = [ + b for b in sections[0]["bullets"] if "Available services" in b + ] assert len(available_line) == 1 assert "svc1" in available_line[0] assert "svc2" in available_line[0] @@ -1179,6 +1230,7 @@ def test_prompt_sections_multiple_services(self) -> None: # Integration-style tests (methods interacting together) # --------------------------------------------------------------------------- + class TestIntegration: """Integration-style tests combining multiple methods.""" @@ -1228,7 +1280,8 @@ def request_side_effect(method: str, url: str, **kwargs: Any) -> Mock: # Find the tool call that registered the 'add' function tool_calls = [ - c for c in agent.define_tool.call_args_list + c + for c in agent.define_tool.call_args_list if c.kwargs.get("name") == "mcp_math_svc_add" ] assert len(tool_calls) == 1 @@ -1244,7 +1297,9 @@ def request_side_effect(method: str, url: str, **kwargs: Any) -> Mock: @patch("signalwire.utils.url_validator.validate_url", return_value=True) @patch("signalwire.skills.mcp_gateway.skill.requests.request") @patch("signalwire.skills.mcp_gateway.skill.requests.get") - def test_full_lifecycle(self, mock_get: Mock, mock_request: Mock, mock_validate: Mock) -> None: + def test_full_lifecycle( + self, mock_get: Mock, mock_request: Mock, mock_validate: Mock + ) -> None: """Test full skill lifecycle: setup -> register -> call -> hangup.""" # Health check health_response = Mock() diff --git a/tests/unit/skills/test_native_vector_search_skill.py b/tests/unit/skills/test_native_vector_search_skill.py index 0941eadf..f6bff76a 100644 --- a/tests/unit/skills/test_native_vector_search_skill.py +++ b/tests/unit/skills/test_native_vector_search_skill.py @@ -9,6 +9,7 @@ Unit tests for NativeVectorSearchSkill """ +from pathlib import Path from typing import Any from collections.abc import Callable @@ -23,6 +24,7 @@ # bypassing any heavy imports that the real __init__ chain may trigger. # --------------------------------------------------------------------------- + def _make_skill(params: dict[str, Any] | None = None) -> NativeVectorSearchSkill: """Instantiate NativeVectorSearchSkill with a mocked agent.""" mock_agent = Mock() @@ -38,31 +40,38 @@ def _make_skill(params: dict[str, Any] | None = None) -> NativeVectorSearchSkill # Class attributes and parameter schema # =========================================================================== + class TestSkillClassAttributes: """Verify class-level constants on NativeVectorSearchSkill.""" def test_skill_name(self) -> None: from signalwire.skills.native_vector_search.skill import NativeVectorSearchSkill + assert NativeVectorSearchSkill.SKILL_NAME == "native_vector_search" def test_skill_description(self) -> None: from signalwire.skills.native_vector_search.skill import NativeVectorSearchSkill + assert "vector" in NativeVectorSearchSkill.SKILL_DESCRIPTION.lower() def test_skill_version(self) -> None: from signalwire.skills.native_vector_search.skill import NativeVectorSearchSkill + assert NativeVectorSearchSkill.SKILL_VERSION == "1.0.0" def test_supports_multiple_instances(self) -> None: from signalwire.skills.native_vector_search.skill import NativeVectorSearchSkill + assert NativeVectorSearchSkill.SUPPORTS_MULTIPLE_INSTANCES is True def test_required_packages_empty(self) -> None: from signalwire.skills.native_vector_search.skill import NativeVectorSearchSkill + assert NativeVectorSearchSkill.REQUIRED_PACKAGES == [] def test_required_env_vars_empty(self) -> None: from signalwire.skills.native_vector_search.skill import NativeVectorSearchSkill + assert NativeVectorSearchSkill.REQUIRED_ENV_VARS == [] @@ -71,40 +80,66 @@ class TestParameterSchema: def test_schema_has_expected_keys(self) -> None: from signalwire.skills.native_vector_search.skill import NativeVectorSearchSkill + schema = NativeVectorSearchSkill.get_parameter_schema() expected_keys = [ - "index_file", "build_index", "source_dir", "remote_url", - "index_name", "count", "similarity_threshold", "tags", - "global_tags", "file_types", "exclude_patterns", - "no_results_message", "response_prefix", "response_postfix", - "max_content_length", "response_format_callback", "description", - "hints", "nlp_backend", "query_nlp_backend", "index_nlp_backend", - "backend", "connection_string", "collection_name", "verbose", - "keyword_weight", "model_name", "overwrite", + "index_file", + "build_index", + "source_dir", + "remote_url", + "index_name", + "count", + "similarity_threshold", + "tags", + "global_tags", + "file_types", + "exclude_patterns", + "no_results_message", + "response_prefix", + "response_postfix", + "max_content_length", + "response_format_callback", + "description", + "hints", + "nlp_backend", + "query_nlp_backend", + "index_nlp_backend", + "backend", + "connection_string", + "collection_name", + "verbose", + "keyword_weight", + "model_name", + "overwrite", # inherited from SkillBase - "swaig_fields", "tool_name", + "swaig_fields", + "tool_name", ] for key in expected_keys: assert key in schema, f"Missing schema key: {key}" def test_schema_count_defaults_to_five(self) -> None: from signalwire.skills.native_vector_search.skill import NativeVectorSearchSkill + schema = NativeVectorSearchSkill.get_parameter_schema() assert schema["count"]["default"] == 5 def test_schema_backend_enum(self) -> None: from signalwire.skills.native_vector_search.skill import NativeVectorSearchSkill + schema = NativeVectorSearchSkill.get_parameter_schema() assert set(schema["backend"]["enum"]) == {"sqlite", "pgvector"} def test_schema_nlp_backend_enum(self) -> None: from signalwire.skills.native_vector_search.skill import NativeVectorSearchSkill + schema = NativeVectorSearchSkill.get_parameter_schema() assert set(schema["nlp_backend"]["enum"]) == {"basic", "spacy", "nltk"} def test_schema_model_name_default(self) -> None: from signalwire.skills.native_vector_search.skill import NativeVectorSearchSkill + schema = NativeVectorSearchSkill.get_parameter_schema() assert schema["model_name"]["default"] == "mini" @@ -113,6 +148,7 @@ def test_schema_model_name_default(self) -> None: # get_instance_key # =========================================================================== + class TestGetInstanceKey: """Test the get_instance_key method.""" @@ -121,22 +157,26 @@ def test_default_instance_key(self) -> None: key = skill.get_instance_key() assert key == "native_vector_search_search_knowledge_default" - def test_custom_tool_name_and_index_file(self) -> None: - skill = _make_skill({"tool_name": "my_tool", "index_file": "/tmp/test.swsearch"}) # noqa: S108 + def test_custom_tool_name_and_index_file(self, tmp_path: Path) -> None: + index_file = str(tmp_path / "test.swsearch") + skill = _make_skill({"tool_name": "my_tool", "index_file": index_file}) key = skill.get_instance_key() - assert key == "native_vector_search_my_tool_/tmp/test.swsearch" + assert key == f"native_vector_search_my_tool_{index_file}" # =========================================================================== # setup() -- remote mode # =========================================================================== + class TestSetupRemoteMode: """Test setup() when remote_url is configured.""" @patch("signalwire.utils.url_validator.validate_url", return_value=True) @patch("signalwire.skills.native_vector_search.skill.requests", create=True) - def test_remote_setup_success(self, mock_requests_mod: Mock, mock_validate: Mock) -> None: + def test_remote_setup_success( + self, mock_requests_mod: Mock, mock_validate: Mock + ) -> None: """Successful health check sets use_remote=True and search_available=True.""" mock_response = Mock() mock_response.status_code = 200 @@ -154,7 +194,9 @@ def test_remote_setup_success(self, mock_requests_mod: Mock, mock_validate: Mock @patch("signalwire.utils.url_validator.validate_url", return_value=True) @patch("signalwire.skills.native_vector_search.skill.requests", create=True) - def test_remote_setup_auth_failure(self, mock_requests_mod: Mock, mock_validate: Mock) -> None: + def test_remote_setup_auth_failure( + self, mock_requests_mod: Mock, mock_validate: Mock + ) -> None: """401 from remote server means search_available=False.""" mock_response = Mock() mock_response.status_code = 401 @@ -170,7 +212,9 @@ def test_remote_setup_auth_failure(self, mock_requests_mod: Mock, mock_validate: @patch("signalwire.utils.url_validator.validate_url", return_value=True) @patch("signalwire.skills.native_vector_search.skill.requests", create=True) - def test_remote_setup_non_200_status(self, mock_requests_mod: Mock, mock_validate: Mock) -> None: + def test_remote_setup_non_200_status( + self, mock_requests_mod: Mock, mock_validate: Mock + ) -> None: """Non-200 and non-401 status returns False.""" mock_response = Mock() mock_response.status_code = 500 @@ -234,22 +278,31 @@ def test_remote_url_without_auth(self) -> None: # setup() -- local mode (no remote_url) # =========================================================================== + class TestSetupLocalMode: """Test setup() when no remote_url is set (local mode).""" def test_local_setup_search_import_failure(self) -> None: """When search dependencies are missing, setup still returns True.""" - with patch.dict("sys.modules", { - "signalwire.search": None, - }), patch( - "signalwire.skills.native_vector_search.skill.NativeVectorSearchSkill.setup" - ) as _: + with ( + patch.dict( + "sys.modules", + { + "signalwire.search": None, + }, + ), + patch( + "signalwire.skills.native_vector_search.skill.NativeVectorSearchSkill.setup" + ) as _, + ): # We need to test the actual method, so call it manually pass # Simpler approach: just call setup and mock the import inside skill = _make_skill() - with patch("builtins.__import__", side_effect=_import_raiser("signalwire.search")): + with patch( + "builtins.__import__", side_effect=_import_raiser("signalwire.search") + ): result = skill.setup() assert result is True @@ -259,7 +312,9 @@ def test_local_setup_search_import_failure(self) -> None: def test_local_setup_default_params(self) -> None: """Default local setup populates expected attributes.""" skill = _make_skill() - with patch("builtins.__import__", side_effect=_import_raiser("signalwire.search")): + with patch( + "builtins.__import__", side_effect=_import_raiser("signalwire.search") + ): skill.setup() assert skill.tool_name == "search_knowledge" @@ -285,7 +340,9 @@ def test_local_setup_custom_params(self) -> None: "model_name": "base", } skill = _make_skill(params) - with patch("builtins.__import__", side_effect=_import_raiser("signalwire.search")): + with patch( + "builtins.__import__", side_effect=_import_raiser("signalwire.search") + ): skill.setup() assert skill.tool_name == "custom_search" @@ -300,7 +357,9 @@ def test_local_setup_custom_params(self) -> None: def test_deprecated_nlp_backend_warning(self) -> None: """Using deprecated 'nlp_backend' param triggers a warning and applies to both backends.""" skill = _make_skill({"nlp_backend": "spacy"}) - with patch("builtins.__import__", side_effect=_import_raiser("signalwire.search")): + with patch( + "builtins.__import__", side_effect=_import_raiser("signalwire.search") + ): skill.setup() assert skill.index_nlp_backend == "spacy" @@ -308,11 +367,15 @@ def test_deprecated_nlp_backend_warning(self) -> None: def test_invalid_nlp_backend_fallback(self) -> None: """Invalid NLP backend names fall back to 'basic'.""" - skill = _make_skill({ - "index_nlp_backend": "invalid_backend", - "query_nlp_backend": "another_invalid", - }) - with patch("builtins.__import__", side_effect=_import_raiser("signalwire.search")): + skill = _make_skill( + { + "index_nlp_backend": "invalid_backend", + "query_nlp_backend": "another_invalid", + } + ) + with patch( + "builtins.__import__", side_effect=_import_raiser("signalwire.search") + ): skill.setup() assert skill.index_nlp_backend == "basic" @@ -331,10 +394,16 @@ def test_local_setup_sqlite_with_existing_index(self) -> None: mock_query_processor = Mock() mock_query_processor.preprocess_query = Mock() - with patch.dict("sys.modules", { - "signalwire.search": mock_search_mod, - "signalwire.search.query_processor": mock_query_processor, - }), patch("os.path.exists", return_value=True): + with ( + patch.dict( + "sys.modules", + { + "signalwire.search": mock_search_mod, + "signalwire.search.query_processor": mock_query_processor, + }, + ), + patch("os.path.exists", return_value=True), + ): skill = _make_skill({"index_file": "/tmp/test.swsearch"}) # noqa: S108 result = skill.setup() @@ -347,10 +416,16 @@ def test_local_setup_sqlite_index_not_found(self) -> None: mock_search_mod = Mock() mock_query_processor = Mock() - with patch.dict("sys.modules", { - "signalwire.search": mock_search_mod, - "signalwire.search.query_processor": mock_query_processor, - }), patch("os.path.exists", return_value=False): + with ( + patch.dict( + "sys.modules", + { + "signalwire.search": mock_search_mod, + "signalwire.search.query_processor": mock_query_processor, + }, + ), + patch("os.path.exists", return_value=False), + ): skill = _make_skill({"index_file": "/tmp/nonexistent.swsearch"}) # noqa: S108 result = skill.setup() @@ -368,15 +443,20 @@ def test_local_setup_pgvector_success(self) -> None: mock_query_processor = Mock() - with patch.dict("sys.modules", { - "signalwire.search": mock_search_mod, - "signalwire.search.query_processor": mock_query_processor, - }): - skill = _make_skill({ - "backend": "pgvector", - "connection_string": "postgresql://user:pass@localhost:5432/db", - "collection_name": "my_collection", - }) + with patch.dict( + "sys.modules", + { + "signalwire.search": mock_search_mod, + "signalwire.search.query_processor": mock_query_processor, + }, + ): + skill = _make_skill( + { + "backend": "pgvector", + "connection_string": "postgresql://user:pass@localhost:5432/db", + "collection_name": "my_collection", + } + ) result = skill.setup() assert result is True @@ -393,10 +473,13 @@ def test_local_setup_pgvector_missing_params(self) -> None: mock_search_mod = Mock() mock_query_processor = Mock() - with patch.dict("sys.modules", { - "signalwire.search": mock_search_mod, - "signalwire.search.query_processor": mock_query_processor, - }): + with patch.dict( + "sys.modules", + { + "signalwire.search": mock_search_mod, + "signalwire.search.query_processor": mock_query_processor, + }, + ): skill = _make_skill({"backend": "pgvector"}) result = skill.setup() @@ -409,15 +492,20 @@ def test_local_setup_pgvector_connection_failure(self) -> None: mock_search_mod.SearchEngine = Mock(side_effect=Exception("Connection refused")) mock_query_processor = Mock() - with patch.dict("sys.modules", { - "signalwire.search": mock_search_mod, - "signalwire.search.query_processor": mock_query_processor, - }): - skill = _make_skill({ - "backend": "pgvector", - "connection_string": "postgresql://localhost/db", - "collection_name": "col", - }) + with patch.dict( + "sys.modules", + { + "signalwire.search": mock_search_mod, + "signalwire.search.query_processor": mock_query_processor, + }, + ): + skill = _make_skill( + { + "backend": "pgvector", + "connection_string": "postgresql://localhost/db", + "collection_name": "col", + } + ) result = skill.setup() assert result is True @@ -428,6 +516,7 @@ def test_local_setup_pgvector_connection_failure(self) -> None: # setup() -- auto-build index # =========================================================================== + class TestSetupAutoBuild: """Test setup() when build_index is True.""" @@ -446,15 +535,23 @@ def test_auto_build_sqlite_generates_index_name(self) -> None: mock_query_processor = Mock() - with patch.dict("sys.modules", { - "signalwire.search": mock_search_mod, - "signalwire.search.models": mock_models, - "signalwire.search.query_processor": mock_query_processor, - }), patch("os.path.exists", return_value=False): - skill = _make_skill({ - "build_index": True, - "source_dir": "/data/my_docs", - }) + with ( + patch.dict( + "sys.modules", + { + "signalwire.search": mock_search_mod, + "signalwire.search.models": mock_models, + "signalwire.search.query_processor": mock_query_processor, + }, + ), + patch("os.path.exists", return_value=False), + ): + skill = _make_skill( + { + "build_index": True, + "source_dir": "/data/my_docs", + } + ) skill.setup() # index_file should be derived from source_dir name @@ -466,15 +563,23 @@ def test_auto_build_sqlite_skips_existing_index(self) -> None: mock_search_mod = Mock() mock_query_processor = Mock() - with patch.dict("sys.modules", { - "signalwire.search": mock_search_mod, - "signalwire.search.query_processor": mock_query_processor, - }), patch("os.path.exists", return_value=True): - skill = _make_skill({ - "build_index": True, - "source_dir": "/data/docs", - "index_file": "/tmp/existing.swsearch", # noqa: S108 - }) + with ( + patch.dict( + "sys.modules", + { + "signalwire.search": mock_search_mod, + "signalwire.search.query_processor": mock_query_processor, + }, + ), + patch("os.path.exists", return_value=True), + ): + skill = _make_skill( + { + "build_index": True, + "source_dir": "/data/docs", + "index_file": "/tmp/existing.swsearch", # noqa: S108 + } + ) skill.setup() # IndexBuilder should NOT have been called since index already exists @@ -497,16 +602,24 @@ def test_auto_build_sqlite_failure(self) -> None: mock_query_processor = Mock() - with patch.dict("sys.modules", { - "signalwire.search": mock_search_mod, - "signalwire.search.models": mock_models, - "signalwire.search.query_processor": mock_query_processor, - }), patch("os.path.exists", return_value=False): - skill = _make_skill({ - "build_index": True, - "source_dir": "/data/docs", - "index_file": "/tmp/test.swsearch", # noqa: S108 - }) + with ( + patch.dict( + "sys.modules", + { + "signalwire.search": mock_search_mod, + "signalwire.search.models": mock_models, + "signalwire.search.query_processor": mock_query_processor, + }, + ), + patch("os.path.exists", return_value=False), + ): + skill = _make_skill( + { + "build_index": True, + "source_dir": "/data/docs", + "index_file": "/tmp/test.swsearch", # noqa: S108 + } + ) result = skill.setup() assert result is True @@ -527,18 +640,23 @@ def test_auto_build_pgvector(self) -> None: mock_query_processor = Mock() - with patch.dict("sys.modules", { - "signalwire.search": mock_search_mod, - "signalwire.search.models": mock_models, - "signalwire.search.query_processor": mock_query_processor, - }): - skill = _make_skill({ - "build_index": True, - "source_dir": "/data/docs", - "backend": "pgvector", - "connection_string": "postgresql://localhost/db", - "collection_name": "my_col", - }) + with patch.dict( + "sys.modules", + { + "signalwire.search": mock_search_mod, + "signalwire.search.models": mock_models, + "signalwire.search.query_processor": mock_query_processor, + }, + ): + skill = _make_skill( + { + "build_index": True, + "source_dir": "/data/docs", + "backend": "pgvector", + "connection_string": "postgresql://localhost/db", + "collection_name": "my_col", + } + ) skill.setup() mock_builder_cls.assert_called_once_with( @@ -555,14 +673,21 @@ def test_auto_build_pgvector(self) -> None: # register_tools() # =========================================================================== + class TestRegisterTools: """Test register_tools method.""" - def _setup_skill_for_register(self, params: dict[str, Any] | None = None) -> NativeVectorSearchSkill: + def _setup_skill_for_register( + self, params: dict[str, Any] | None = None + ) -> NativeVectorSearchSkill: """Helper to create a skill ready for register_tools.""" skill = _make_skill(params or {}) # Manually set attributes that setup() would set - skill.tool_name = params.get("tool_name", "search_knowledge") if params else "search_knowledge" + skill.tool_name = ( + params.get("tool_name", "search_knowledge") + if params + else "search_knowledge" + ) skill.count = params.get("count", 5) if params else 5 skill.use_remote = False return skill @@ -576,7 +701,10 @@ def test_register_tools_defines_tool(self) -> None: call_kwargs = skill.agent.define_tool.call_args # define_tool is called via self.define_tool which delegates to agent.define_tool # Check that the tool name and handler are present - assert call_kwargs.kwargs.get("name") or call_kwargs[1].get("name") == "search_knowledge" + assert ( + call_kwargs.kwargs.get("name") + or call_kwargs[1].get("name") == "search_knowledge" + ) def test_register_tools_creates_knowledge_search_section(self) -> None: """When section does not exist, a new one is created.""" @@ -614,6 +742,7 @@ def test_register_tools_custom_description(self) -> None: # _search_handler() # =========================================================================== + class TestSearchHandler: """Test the _search_handler method.""" @@ -644,7 +773,9 @@ def _setup_skill_for_search(self, **overrides: Any) -> NativeVectorSearchSkill: def test_search_unavailable(self) -> None: """When search is not available, return an error message.""" - skill = self._setup_skill_for_search(search_available=False, import_error="missing dep") + skill = self._setup_skill_for_search( + search_available=False, import_error="missing dep" + ) result = skill._search_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) @@ -684,13 +815,20 @@ def test_missing_query_key(self) -> None: def test_local_search_no_results(self) -> None: """Local search returning no results uses no_results_message.""" - mock_preprocess = Mock(return_value={"enhanced_text": "test", "vector": [0.1, 0.2]}) + mock_preprocess = Mock( + return_value={"enhanced_text": "test", "vector": [0.1, 0.2]} + ) skill = self._setup_skill_for_search() skill.search_engine.search.return_value = [] # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) @@ -705,9 +843,14 @@ def test_local_search_no_results_with_prefix_postfix(self) -> None: ) skill.search_engine.search.return_value = [] # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "test"}, {}) assert result.response.startswith("[START]") @@ -715,7 +858,9 @@ def test_local_search_no_results_with_prefix_postfix(self) -> None: def test_local_search_with_results(self) -> None: """Successful local search formats results correctly.""" - mock_preprocess = Mock(return_value={"enhanced_text": "test query", "vector": [0.1]}) + mock_preprocess = Mock( + return_value={"enhanced_text": "test query", "vector": [0.1]} + ) skill = self._setup_skill_for_search() skill.search_engine.search.return_value = [ # type: ignore[union-attr] # mock search_engine { @@ -727,9 +872,14 @@ def test_local_search_with_results(self) -> None: ] skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "test query"}, {}) assert isinstance(result, FunctionResult) @@ -744,14 +894,29 @@ def test_local_search_with_multiple_results(self) -> None: mock_preprocess = Mock(return_value={"enhanced_text": "q", "vector": [0.1]}) skill = self._setup_skill_for_search() skill.search_engine.search.return_value = [ # type: ignore[union-attr] # mock search_engine - {"content": "Answer 1", "score": 0.9, "metadata": {"filename": "a.md"}, "tags": []}, - {"content": "Answer 2", "score": 0.8, "metadata": {"filename": "b.md"}, "tags": []}, + { + "content": "Answer 1", + "score": 0.9, + "metadata": {"filename": "a.md"}, + "tags": [], + }, + { + "content": "Answer 2", + "score": 0.8, + "metadata": {"filename": "b.md"}, + "tags": [], + }, ] skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "q"}, {}) assert "Found 2 relevant results" in result.response @@ -764,13 +929,23 @@ def test_local_search_content_truncation(self) -> None: skill = self._setup_skill_for_search(max_content_length=1500) long_content = "x" * 5000 skill.search_engine.search.return_value = [ # type: ignore[union-attr] # mock search_engine - {"content": long_content, "score": 0.9, "metadata": {"filename": "a.md"}, "tags": []}, + { + "content": long_content, + "score": 0.9, + "metadata": {"filename": "a.md"}, + "tags": [], + }, ] skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "q"}, {}) # The content should be truncated and end with "..." @@ -785,13 +960,23 @@ def test_local_search_with_prefix_postfix(self) -> None: response_postfix="<>", ) skill.search_engine.search.return_value = [ # type: ignore[union-attr] # mock search_engine - {"content": "Answer", "score": 0.9, "metadata": {"filename": "a.md"}, "tags": []}, + { + "content": "Answer", + "score": 0.9, + "metadata": {"filename": "a.md"}, + "tags": [], + }, ] skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "q"}, {}) assert "<>" in result.response @@ -804,9 +989,14 @@ def test_local_search_count_override(self) -> None: skill.search_engine.search.return_value = [] # type: ignore[union-attr] # mock search_engine skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): skill._search_handler({"query": "q", "count": 3}, {}) # Check that search was called with count=3 @@ -819,9 +1009,14 @@ def test_search_exception_handling_generic(self) -> None: skill = self._setup_skill_for_search() skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) @@ -834,9 +1029,14 @@ def test_search_exception_handling_nltk(self) -> None: skill = self._setup_skill_for_search() skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "test"}, {}) assert "language processing" in result.response.lower() @@ -847,9 +1047,14 @@ def test_search_exception_handling_vector(self) -> None: skill = self._setup_skill_for_search() skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "test"}, {}) assert "indexing" in result.response.lower() @@ -860,9 +1065,14 @@ def test_search_exception_handling_timeout(self) -> None: skill = self._setup_skill_for_search() skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "test"}, {}) assert "temporarily unavailable" in result.response.lower() @@ -876,13 +1086,23 @@ def my_callback(**kwargs: Any) -> str: skill = self._setup_skill_for_search(response_format_callback=my_callback) skill.search_engine.search.return_value = [ # type: ignore[union-attr] # mock search_engine - {"content": "Answer", "score": 0.9, "metadata": {"filename": "a.md"}, "tags": []}, + { + "content": "Answer", + "score": 0.9, + "metadata": {"filename": "a.md"}, + "tags": [], + }, ] skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "hello"}, {}) assert result.response == "CUSTOM: hello" @@ -898,9 +1118,14 @@ def my_callback(**kwargs: Any) -> str: skill.search_engine.search.return_value = [] # type: ignore[union-attr] # mock search_engine skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "hello"}, {}) assert result.response == "CUSTOM NO RESULTS" @@ -914,13 +1139,23 @@ def bad_callback(**kwargs: Any) -> int: skill = self._setup_skill_for_search(response_format_callback=bad_callback) skill.search_engine.search.return_value = [ # type: ignore[union-attr] # mock search_engine - {"content": "Answer", "score": 0.9, "metadata": {"filename": "a.md"}, "tags": []}, + { + "content": "Answer", + "score": 0.9, + "metadata": {"filename": "a.md"}, + "tags": [], + }, ] skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "hello"}, {}) # Should fall back to the original formatted response @@ -933,15 +1168,27 @@ def test_response_format_callback_exception(self) -> None: def exploding_callback(**kwargs: Any) -> str: raise ValueError("boom") - skill = self._setup_skill_for_search(response_format_callback=exploding_callback) + skill = self._setup_skill_for_search( + response_format_callback=exploding_callback + ) skill.search_engine.search.return_value = [ # type: ignore[union-attr] # mock search_engine - {"content": "Answer", "score": 0.9, "metadata": {"filename": "a.md"}, "tags": []}, + { + "content": "Answer", + "score": 0.9, + "metadata": {"filename": "a.md"}, + "tags": [], + }, ] skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "hello"}, {}) # Should still have a valid response @@ -964,9 +1211,14 @@ def test_tags_from_metadata_nested(self) -> None: ] skill.search_engine.config = {} # type: ignore[union-attr] # mock search_engine - with patch.dict("sys.modules", { - "signalwire.search.query_processor": Mock(preprocess_query=mock_preprocess), - }): + with patch.dict( + "sys.modules", + { + "signalwire.search.query_processor": Mock( + preprocess_query=mock_preprocess + ), + }, + ): result = skill._search_handler({"query": "q"}, {}) assert "tag1" in result.response @@ -977,6 +1229,7 @@ def test_tags_from_metadata_nested(self) -> None: # _search_remote() # =========================================================================== + class TestSearchRemote: """Test the _search_remote method.""" @@ -1103,6 +1356,7 @@ def test_remote_search_empty_results_array(self) -> None: # _search_handler() with remote mode # =========================================================================== + class TestSearchHandlerRemoteMode: """Test _search_handler when use_remote=True.""" @@ -1131,9 +1385,16 @@ def _setup_remote_skill_for_search(self) -> NativeVectorSearchSkill: def test_remote_handler_calls_search_remote(self) -> None: """Handler in remote mode calls _search_remote.""" skill = self._setup_remote_skill_for_search() - skill._search_remote = Mock(return_value=[ # type: ignore[method-assign] # mock - {"content": "Result", "score": 0.9, "metadata": {"filename": "r.md"}, "tags": []}, - ]) + skill._search_remote = Mock( # type: ignore[method-assign] # mock + return_value=[ + { + "content": "Result", + "score": 0.9, + "metadata": {"filename": "r.md"}, + "tags": [], + }, + ] + ) result = skill._search_handler({"query": "test"}, {}) @@ -1154,6 +1415,7 @@ def test_remote_handler_no_results(self) -> None: # get_hints(), get_global_data(), get_prompt_sections(), cleanup() # =========================================================================== + class TestMiscMethods: """Test auxiliary methods on the skill.""" @@ -1198,22 +1460,18 @@ def test_get_global_data_engine_error(self) -> None: data = skill.get_global_data() assert data == {} - def test_get_prompt_sections_returns_empty(self) -> None: - skill = _make_skill() - assert skill.get_prompt_sections() == [] - def test_cleanup_no_temp_dirs(self) -> None: """cleanup must early-return when _temp_dirs is unset, and must NOT invoke shutil.rmtree at all in that path.""" skill = _make_skill() # Pre-condition: skill has no _temp_dirs attribute. - assert not hasattr(skill, '_temp_dirs') + assert not hasattr(skill, "_temp_dirs") with patch("shutil.rmtree") as mock_rmtree: skill.cleanup() # No rmtree calls because the hasattr guard short-circuits. assert mock_rmtree.call_count == 0 # The attribute is still absent — cleanup didn't invent one. - assert not hasattr(skill, '_temp_dirs') + assert not hasattr(skill, "_temp_dirs") def test_cleanup_with_temp_dirs(self) -> None: """cleanup removes temp directories.""" @@ -1232,7 +1490,9 @@ def test_cleanup_rmtree_error_ignored(self) -> None: skill = _make_skill() skill._temp_dirs = ["/tmp/fake_dir1", "/tmp/fake_dir2", "/tmp/fake_dir3"] # type: ignore[attr-defined] # noqa: S108 # dynamic optional attr, read via hasattr guard in cleanup - with patch("shutil.rmtree", side_effect=OSError("permission denied")) as mock_rmtree: + with patch( + "shutil.rmtree", side_effect=OSError("permission denied") + ) as mock_rmtree: skill.cleanup() # All three dirs were attempted even though every call raised. assert mock_rmtree.call_count == 3 @@ -1241,54 +1501,52 @@ def test_cleanup_rmtree_error_ignored(self) -> None: # =========================================================================== -# _add_prompt_section() +# _get_prompt_sections() # =========================================================================== -class TestAddPromptSection: - """Test _add_prompt_section method.""" - def test_add_prompt_section_success(self) -> None: - skill = _make_skill() - skill.tool_name = "my_search" - mock_agent = Mock() +class TestGetPromptSections: + """Test the prompt-section hook. - skill._add_prompt_section(mock_agent) + This skill previously returned ``[]`` from the hook and kept its real + content in a push-style ``_add_prompt_section(agent)`` helper that nothing + ever called, so the skill contributed no prompt section at all. These tests + pin the content to the pull-style hook. + """ - mock_agent.prompt_add_section.assert_called_once() - call_kwargs = mock_agent.prompt_add_section.call_args[1] - assert call_kwargs["title"] == "Local Document Search" - assert "my_search" in call_kwargs["body"] + def test_returns_one_section_naming_the_tool(self) -> None: + skill = _make_skill(params={"tool_name": "my_search"}) + skill.setup() - def test_add_prompt_section_error_handled(self) -> None: - """A failure inside agent.prompt_add_section must be caught and - logged — the skill must not propagate the exception. We assert the - agent method was actually invoked AND the logger captured the - failure (proving the except branch ran).""" - skill = _make_skill() - skill.tool_name = "search" - mock_agent = Mock() - mock_agent.prompt_add_section.side_effect = Exception("prompt error") + sections = skill.get_prompt_sections() - with patch.object(skill, "logger") as mock_logger: - skill._add_prompt_section(mock_agent) - # The agent was invoked exactly once before the exception bubbled. - mock_agent.prompt_add_section.assert_called_once() - # And the error path logged the failure. - assert mock_logger.error.call_count == 1 - logged = mock_logger.error.call_args[0][0] - assert "prompt error" in logged or "prompt section" in logged.lower() + assert len(sections) == 1 + section = sections[0] + assert section["title"] == "Local Document Search" + assert "my_search" in section["body"] + assert any("my_search" in bullet for bullet in section["bullets"]) + assert len(section["bullets"]) == 4 + + def test_skip_prompt_suppresses_the_section(self) -> None: + skill = _make_skill(params={"tool_name": "my_search", "skip_prompt": True}) + skill.setup() + + assert skill.get_prompt_sections() == [] # =========================================================================== # Helpers # =========================================================================== + def _import_raiser(blocked_module: str) -> Callable[..., Any]: """ Return a side_effect function for patching builtins.__import__ that raises ImportError for a specific module while allowing everything else. """ - real_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__ + real_import = ( + __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__ + ) def _custom_import(name: str, *args: Any, **kwargs: Any) -> Any: if name == blocked_module or name.startswith(blocked_module + "."): diff --git a/tests/unit/skills/test_play_background_file_skill.py b/tests/unit/skills/test_play_background_file_skill.py index 5a45c583..b28ef4b4 100644 --- a/tests/unit/skills/test_play_background_file_skill.py +++ b/tests/unit/skills/test_play_background_file_skill.py @@ -58,7 +58,10 @@ def test_skill_name(self) -> None: assert PlayBackgroundFileSkill.SKILL_NAME == "play_background_file" def test_skill_description(self) -> None: - assert PlayBackgroundFileSkill.SKILL_DESCRIPTION == "Control background file playback" + assert ( + PlayBackgroundFileSkill.SKILL_DESCRIPTION + == "Control background file playback" + ) def test_skill_version(self) -> None: assert PlayBackgroundFileSkill.SKILL_VERSION == "1.0.0" @@ -129,11 +132,15 @@ class TestPlayBackgroundFileSkillValidation: """Tests for _validate_config.""" def test_files_must_be_list(self) -> None: - with pytest.raises(ValueError, match="files parameter must be a non-empty list"): + with pytest.raises( + ValueError, match="files parameter must be a non-empty list" + ): _make_skill({"files": "not-a-list"}) def test_files_must_not_be_empty(self) -> None: - with pytest.raises(ValueError, match="files parameter must be a non-empty list"): + with pytest.raises( + ValueError, match="files parameter must be a non-empty list" + ): _make_skill({"files": []}) def test_file_must_be_dict(self) -> None: @@ -145,7 +152,9 @@ def test_file_missing_key(self) -> None: _make_skill({"files": [{"description": "d", "url": "http://x"}]}) def test_file_missing_description(self) -> None: - with pytest.raises(ValueError, match="File 0 missing required field: description"): + with pytest.raises( + ValueError, match="File 0 missing required field: description" + ): _make_skill({"files": [{"key": "k", "url": "http://x"}]}) def test_file_missing_url(self) -> None: @@ -158,12 +167,23 @@ def test_file_key_empty_string(self) -> None: def test_file_key_whitespace_only(self) -> None: with pytest.raises(ValueError, match="must be a non-empty string"): - _make_skill({"files": [{"key": " ", "description": "d", "url": "http://x"}]}) + _make_skill( + {"files": [{"key": " ", "description": "d", "url": "http://x"}]} + ) def test_file_wait_must_be_boolean(self) -> None: with pytest.raises(ValueError, match="must be a boolean"): _make_skill( - {"files": [{"key": "k", "description": "d", "url": "http://x", "wait": "yes"}]} + { + "files": [ + { + "key": "k", + "description": "d", + "url": "http://x", + "wait": "yes", + } + ] + } ) def test_file_key_invalid_characters(self) -> None: @@ -329,7 +349,10 @@ def test_start_expression_with_wait_true(self) -> None: playback_action = [a for a in actions if "playback_bg" in a] assert len(playback_action) == 1 # wait=True means the value should be a dict with file and wait - assert playback_action[0]["playback_bg"]["file"] == "https://example.com/massey.mp4" + assert ( + playback_action[0]["playback_bg"]["file"] + == "https://example.com/massey.mp4" + ) assert playback_action[0]["playback_bg"]["wait"] is True def test_start_expression_with_wait_false_default(self) -> None: diff --git a/tests/unit/skills/test_registry.py b/tests/unit/skills/test_registry.py index 373c041c..0e1446f9 100644 --- a/tests/unit/skills/test_registry.py +++ b/tests/unit/skills/test_registry.py @@ -21,6 +21,7 @@ class MockSkill(SkillBase): """Mock skill for testing""" + SKILL_NAME = "mock_skill" SKILL_DESCRIPTION = "A mock skill for testing" SKILL_VERSION = "1.0.0" @@ -31,7 +32,11 @@ class MockSkill(SkillBase): @classmethod def get_parameter_schema(cls) -> dict[str, dict[str, Any]]: schema: dict[str, dict[str, Any]] = super().get_parameter_schema() - schema["test_param"] = {"type": "string", "description": "test", "required": False} + schema["test_param"] = { + "type": "string", + "description": "test", + "required": False, + } return schema def setup(self) -> bool: @@ -43,6 +48,7 @@ def register_tools(self) -> None: class AnotherMockSkill(SkillBase): """Another mock skill for testing""" + SKILL_NAME = "another_mock_skill" SKILL_DESCRIPTION = "Another mock skill" SKILL_VERSION = "2.0.0" @@ -53,7 +59,11 @@ class AnotherMockSkill(SkillBase): @classmethod def get_parameter_schema(cls) -> dict[str, dict[str, Any]]: schema: dict[str, dict[str, Any]] = super().get_parameter_schema() - schema["test_param"] = {"type": "string", "description": "test", "required": False} + schema["test_param"] = { + "type": "string", + "description": "test", + "required": False, + } return schema def setup(self) -> bool: @@ -65,18 +75,19 @@ def register_tools(self) -> None: class InvalidSkill(SkillBase): """Invalid skill without SKILL_NAME""" + SKILL_NAME = None - + def setup(self) -> bool: return True - + def register_tools(self) -> None: pass class TestSkillRegistry: """Test SkillRegistry functionality""" - + def test_basic_initialization(self) -> None: """Test basic SkillRegistry initialization""" registry = SkillRegistry() @@ -84,41 +95,43 @@ def test_basic_initialization(self) -> None: assert registry._skills == {} assert registry._entry_points_loaded is False assert registry.logger is not None - + def test_register_skill_basic(self) -> None: """Test basic skill registration""" registry = SkillRegistry() - + registry.register_skill(MockSkill) - + assert "mock_skill" in registry._skills assert registry._skills["mock_skill"] == MockSkill - + def test_register_skill_duplicate(self) -> None: """Test registering duplicate skill""" registry = SkillRegistry() - + registry.register_skill(MockSkill) - + # Register the same skill again - with patch.object(registry.logger, 'warning') as mock_warning: + with patch.object(registry.logger, "warning") as mock_warning: registry.register_skill(MockSkill) - mock_warning.assert_called_once_with("Skill 'mock_skill' already registered") - + mock_warning.assert_called_once_with( + "Skill 'mock_skill' already registered" + ) + # Should still only have one instance assert len(registry._skills) == 1 - + def test_register_multiple_skills(self) -> None: """Test registering multiple skills""" registry = SkillRegistry() - + registry.register_skill(MockSkill) registry.register_skill(AnotherMockSkill) - + assert len(registry._skills) == 2 assert "mock_skill" in registry._skills assert "another_mock_skill" in registry._skills - + def test_get_skill_class_existing(self) -> None: """Test getting existing skill class""" registry = SkillRegistry() @@ -127,26 +140,28 @@ def test_get_skill_class_existing(self) -> None: skill_class = registry.get_skill_class("mock_skill") assert skill_class == MockSkill - + def test_get_skill_class_nonexistent(self) -> None: """Test getting nonexistent skill class""" registry = SkillRegistry() # Mock on-demand loading to prevent real filesystem scanning - with patch.object(registry, '_load_skill_on_demand', return_value=None): + with patch.object(registry, "_load_skill_on_demand", return_value=None): skill_class = registry.get_skill_class("nonexistent_skill") assert skill_class is None - - @patch.object(SkillRegistry, '_load_skill_on_demand', return_value=None) - def test_get_skill_class_triggers_on_demand_loading(self, mock_load: MagicMock) -> None: + + @patch.object(SkillRegistry, "_load_skill_on_demand", return_value=None) + def test_get_skill_class_triggers_on_demand_loading( + self, mock_load: MagicMock + ) -> None: """Test that get_skill_class triggers on-demand loading for unknown skills""" registry = SkillRegistry() registry.get_skill_class("some_skill") mock_load.assert_called_once_with("some_skill") - + def test_list_skills_empty(self) -> None: """Test listing skills when no skill directories exist""" registry = SkillRegistry() @@ -154,12 +169,12 @@ def test_list_skills_empty(self) -> None: # Mock the skills directory to return no subdirectories mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = [] - with patch('signalwire.skills.registry.Path') as mock_path_cls: + with patch("signalwire.skills.registry.Path") as mock_path_cls: mock_path_cls.return_value.parent = mock_skills_dir skills = registry.list_skills() assert skills == [] - + def test_list_skills_with_skills(self) -> None: """Test listing skills with registered skills""" registry = SkillRegistry() @@ -184,7 +199,7 @@ def test_list_skills_with_skills(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = [mock_dir1, mock_dir2] - with patch('signalwire.skills.registry.Path') as mock_path_cls: + with patch("signalwire.skills.registry.Path") as mock_path_cls: mock_path_cls.return_value.parent = mock_skills_dir skills = registry.list_skills() @@ -199,13 +214,15 @@ def test_list_skills_with_skills(self) -> None: assert mock_skill_info["supports_multiple_instances"] is True # Check second skill - another_skill_info = next(s for s in skills if s["name"] == "another_mock_skill") + another_skill_info = next( + s for s in skills if s["name"] == "another_mock_skill" + ) assert another_skill_info["description"] == "Another mock skill" assert another_skill_info["version"] == "2.0.0" assert another_skill_info["required_packages"] == [] assert another_skill_info["required_env_vars"] == [] assert another_skill_info["supports_multiple_instances"] is False - + def test_list_skills_triggers_on_demand_loading(self) -> None: """Test that list_skills triggers on-demand loading for found skill directories""" registry = SkillRegistry() @@ -221,9 +238,11 @@ def test_list_skills_triggers_on_demand_loading(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = [mock_dir] - with patch('signalwire.skills.registry.Path') as mock_path_cls: + with patch("signalwire.skills.registry.Path") as mock_path_cls: mock_path_cls.return_value.parent = mock_skills_dir - with patch.object(registry, '_load_skill_on_demand', return_value=None) as mock_load: + with patch.object( + registry, "_load_skill_on_demand", return_value=None + ) as mock_load: registry.list_skills() mock_load.assert_called_once_with("some_skill") @@ -246,7 +265,9 @@ def test_discover_skills_returns_and_registers_inventory(self) -> None: # loading them on demand populates the registry's _skills cache. assert isinstance(discovered, list) assert len(discovered) == len(registry._skills) - assert registry._skills, "discover_skills() should have registered the scanned skills" + assert registry._skills, ( + "discover_skills() should have registered the scanned skills" + ) def test_entry_points_loaded_idempotent(self) -> None: """Test that _load_entry_points is idempotent""" @@ -254,7 +275,7 @@ def test_entry_points_loaded_idempotent(self) -> None: mock_eps = MagicMock() mock_eps.return_value = MagicMock(select=MagicMock(return_value=[])) - with patch('importlib.metadata.entry_points', mock_eps): + with patch("importlib.metadata.entry_points", mock_eps): registry._load_entry_points() registry._load_entry_points() # Call again @@ -284,11 +305,17 @@ def test_list_skills_scans_directory(self) -> None: mock_file.is_dir.return_value = False mock_skills_dir = Mock() - mock_skills_dir.iterdir.return_value = [mock_skill_dir1, mock_skill_dir2, mock_file] + mock_skills_dir.iterdir.return_value = [ + mock_skill_dir1, + mock_skill_dir2, + mock_file, + ] - with patch('signalwire.skills.registry.Path') as mock_path_cls: + with patch("signalwire.skills.registry.Path") as mock_path_cls: mock_path_cls.return_value.parent = mock_skills_dir - with patch.object(registry, '_load_skill_on_demand', return_value=None) as mock_load: + with patch.object( + registry, "_load_skill_on_demand", return_value=None + ) as mock_load: registry.list_skills() # Should only load from test_skill directory (not __pycache__ or files) @@ -298,13 +325,17 @@ def test_load_skill_on_demand_searches_paths(self) -> None: """Test that _load_skill_on_demand searches built-in and external paths""" registry = SkillRegistry() - with patch.object(registry, '_load_entry_points'): - with patch.object(registry, '_load_skill_from_path', return_value=None) as mock_load_path: - result = registry._load_skill_on_demand("nonexistent_skill") + with ( + patch.object(registry, "_load_entry_points"), + patch.object( + registry, "_load_skill_from_path", return_value=None + ) as mock_load_path, + ): + result = registry._load_skill_on_demand("nonexistent_skill") - assert result is None - # Should have tried loading from the built-in skills directory - assert mock_load_path.call_count >= 1 + assert result is None + # Should have tried loading from the built-in skills directory + assert mock_load_path.call_count >= 1 class TestSkillLoading: @@ -326,10 +357,15 @@ def test_load_skill_from_path_no_skill_file(self) -> None: assert result is None assert len(registry._skills) == 0 - @patch('signalwire.skills.registry.importlib.util.spec_from_file_location') - @patch('signalwire.skills.registry.importlib.util.module_from_spec') - @patch('signalwire.skills.registry.inspect.getmembers') - def test_load_skill_from_path_success(self, mock_getmembers: MagicMock, mock_module_from_spec: MagicMock, mock_spec_from_file: MagicMock) -> None: + @patch("signalwire.skills.registry.importlib.util.spec_from_file_location") + @patch("signalwire.skills.registry.importlib.util.module_from_spec") + @patch("signalwire.skills.registry.inspect.getmembers") + def test_load_skill_from_path_success( + self, + mock_getmembers: MagicMock, + mock_module_from_spec: MagicMock, + mock_spec_from_file: MagicMock, + ) -> None: """Test successful skill loading from path""" registry = SkillRegistry() @@ -358,14 +394,16 @@ def test_load_skill_from_path_success(self, mock_getmembers: MagicMock, mock_mod ("SomeOtherClass", str), # Should be ignored ] - with patch.object(registry, 'register_skill') as mock_register: + with patch.object(registry, "register_skill") as mock_register: result = registry._load_skill_from_path("mock_skill", mock_base_path) # noqa: F841 # Should register the matching skill mock_register.assert_called_once_with(MockSkill) - @patch('signalwire.skills.registry.importlib.util.spec_from_file_location') - def test_load_skill_from_path_import_error(self, mock_spec_from_file: MagicMock) -> None: + @patch("signalwire.skills.registry.importlib.util.spec_from_file_location") + def test_load_skill_from_path_import_error( + self, mock_spec_from_file: MagicMock + ) -> None: """Test skill loading with import error""" registry = SkillRegistry() @@ -381,16 +419,18 @@ def test_load_skill_from_path_import_error(self, mock_spec_from_file: MagicMock) # Mock import error mock_spec_from_file.side_effect = ImportError("Module not found") - with patch.object(registry.logger, 'error') as mock_error: + with patch.object(registry.logger, "error") as mock_error: result = registry._load_skill_from_path("test_skill", mock_base_path) assert result is None mock_error.assert_called_once() assert "Failed to load skill" in mock_error.call_args[0][0] - @patch('signalwire.skills.registry.importlib.util.spec_from_file_location') - @patch('signalwire.skills.registry.importlib.util.module_from_spec') - def test_load_skill_from_path_execution_error(self, mock_module_from_spec: MagicMock, mock_spec_from_file: MagicMock) -> None: + @patch("signalwire.skills.registry.importlib.util.spec_from_file_location") + @patch("signalwire.skills.registry.importlib.util.module_from_spec") + def test_load_skill_from_path_execution_error( + self, mock_module_from_spec: MagicMock, mock_spec_from_file: MagicMock + ) -> None: """Test skill loading with module execution error""" registry = SkillRegistry() @@ -413,7 +453,7 @@ def test_load_skill_from_path_execution_error(self, mock_module_from_spec: Magic mock_module = Mock() mock_module_from_spec.return_value = mock_module - with patch.object(registry.logger, 'error') as mock_error: + with patch.object(registry.logger, "error") as mock_error: result = registry._load_skill_from_path("test_skill", mock_base_path) assert result is None @@ -423,23 +463,23 @@ def test_load_skill_from_path_execution_error(self, mock_module_from_spec: Magic class TestGlobalRegistry: """Test global registry instance""" - + def test_global_registry_exists(self) -> None: """Test that global registry instance exists""" assert skill_registry is not None assert isinstance(skill_registry, SkillRegistry) - + def test_global_registry_singleton_behavior(self) -> None: """Test that global registry behaves like a singleton""" # Import again to get the same instance from signalwire.skills.registry import skill_registry as registry2 - + assert skill_registry is registry2 class TestSkillRegistryIntegration: """Test integration scenarios""" - + def test_complete_skill_workflow(self) -> None: """Test complete skill registration and retrieval workflow""" registry = SkillRegistry() @@ -466,7 +506,7 @@ def test_complete_skill_workflow(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = [mock_dir1, mock_dir2] - with patch('signalwire.skills.registry.Path') as mock_path_cls: + with patch("signalwire.skills.registry.Path") as mock_path_cls: mock_path_cls.return_value.parent = mock_skills_dir # List all skills @@ -481,10 +521,10 @@ def test_complete_skill_workflow(self) -> None: assert another_skill == AnotherMockSkill # Try to get nonexistent skill - with patch.object(registry, '_load_skill_on_demand', return_value=None): + with patch.object(registry, "_load_skill_on_demand", return_value=None): nonexistent = registry.get_skill_class("nonexistent") assert nonexistent is None - + def test_skill_metadata_completeness(self) -> None: """Test that skill metadata is complete and correct""" registry = SkillRegistry() @@ -501,16 +541,19 @@ def test_skill_metadata_completeness(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = [mock_dir] - with patch('signalwire.skills.registry.Path') as mock_path_cls: + with patch("signalwire.skills.registry.Path") as mock_path_cls: mock_path_cls.return_value.parent = mock_skills_dir skills = registry.list_skills() skill_info = skills[0] # Verify all expected fields are present expected_fields = [ - "name", "description", "version", - "required_packages", "required_env_vars", - "supports_multiple_instances" + "name", + "description", + "version", + "required_packages", + "required_env_vars", + "supports_multiple_instances", ] for field in expected_fields: @@ -523,26 +566,26 @@ def test_skill_metadata_completeness(self) -> None: assert skill_info["required_packages"] == ["requests"] assert skill_info["required_env_vars"] == ["API_KEY"] assert skill_info["supports_multiple_instances"] is True - + def test_registry_state_isolation(self) -> None: """Test that different registry instances are isolated""" registry1 = SkillRegistry() registry2 = SkillRegistry() - + registry1.register_skill(MockSkill) - + # registry2 should not have the skill assert len(registry1._skills) == 1 assert len(registry2._skills) == 0 - + # But both should be able to register skills independently registry2.register_skill(AnotherMockSkill) - + assert "mock_skill" in registry1._skills assert "mock_skill" not in registry2._skills assert "another_mock_skill" not in registry1._skills assert "another_mock_skill" in registry2._skills - + def test_error_recovery(self) -> None: """Test that registry can recover from errors""" registry = SkillRegistry() @@ -559,9 +602,14 @@ def test_error_recovery(self) -> None: mock_skill_dir.__truediv__ = Mock(return_value=mock_skill_file) mock_base_path.__truediv__ = Mock(return_value=mock_skill_dir) - with patch('signalwire.skills.registry.importlib.util.spec_from_file_location', side_effect=Exception("Bad import")): - with patch.object(registry.logger, 'error'): - result = registry._load_skill_from_path("bad_skill", mock_base_path) + with ( + patch( + "signalwire.skills.registry.importlib.util.spec_from_file_location", + side_effect=Exception("Bad import"), + ), + patch.object(registry.logger, "error"), + ): + result = registry._load_skill_from_path("bad_skill", mock_base_path) assert result is None @@ -578,8 +626,10 @@ def test_error_recovery(self) -> None: # Helper mock skills used by new test classes # --------------------------------------------------------------------------- + class _SecondMockSkill(SkillBase): """A second mock skill with a different name for multi-match testing""" + SKILL_NAME = "second_mock" SKILL_DESCRIPTION = "Second mock" SKILL_VERSION = "1.0.0" @@ -602,6 +652,7 @@ def register_tools(self) -> None: class _NoParamSchemaSkill(SkillBase): """Skill that does not override get_parameter_schema (uses base only)""" + SKILL_NAME = "no_param_schema" SKILL_DESCRIPTION = "No param schema" SUPPORTS_MULTIPLE_INSTANCES = False @@ -617,10 +668,13 @@ def register_tools(self) -> None: # TestListAllSkillSources # --------------------------------------------------------------------------- + class TestListAllSkillSources: """Test listing built-in + external skill directories via list_all_skill_sources.""" - def _make_dir_entry(self, name: str, has_skill_py: bool = True, is_dir: bool = True) -> Mock: + def _make_dir_entry( + self, name: str, has_skill_py: bool = True, is_dir: bool = True + ) -> Mock: """Helper to create a mock directory entry.""" entry = Mock() entry.is_dir.return_value = is_dir @@ -635,16 +689,23 @@ def test_empty_registry_returns_all_categories(self) -> None: registry = SkillRegistry() mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = [] - with patch.object(Path, '__new__', return_value=mock_skills_dir): + with ( + patch.object(Path, "__new__", return_value=mock_skills_dir), # Easier: patch the parent property used inside the method - with patch('signalwire.skills.registry.Path') as MockPath: - # Path(__file__).parent -> mock_skills_dir - MockPath.return_value.parent = mock_skills_dir - sources = registry.list_all_skill_sources() - assert set(sources.keys()) == {'built-in', 'external_paths', 'entry_points', 'registered'} - assert sources['built-in'] == [] - assert sources['external_paths'] == [] - assert sources['registered'] == [] + patch("signalwire.skills.registry.Path") as MockPath, + ): + # Path(__file__).parent -> mock_skills_dir + MockPath.return_value.parent = mock_skills_dir + sources = registry.list_all_skill_sources() + assert set(sources.keys()) == { + "built-in", + "external_paths", + "entry_points", + "registered", + } + assert sources["built-in"] == [] + assert sources["external_paths"] == [] + assert sources["registered"] == [] def test_builtin_skills_listed(self) -> None: """Built-in skill directories with skill.py are listed.""" @@ -656,11 +717,11 @@ def test_builtin_skills_listed(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = entries - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir sources = registry.list_all_skill_sources() - assert sorted(sources['built-in']) == ['math', 'weather'] + assert sorted(sources["built-in"]) == ["math", "weather"] def test_builtin_skips_dunder_dirs(self) -> None: """Directories starting with __ are excluded from built-in list.""" @@ -673,11 +734,11 @@ def test_builtin_skips_dunder_dirs(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = entries - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir sources = registry.list_all_skill_sources() - assert sources['built-in'] == ['real_skill'] + assert sources["built-in"] == ["real_skill"] def test_builtin_skips_non_dir_items(self) -> None: """Non-directory items in the skills folder are ignored.""" @@ -689,11 +750,11 @@ def test_builtin_skips_non_dir_items(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = entries - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir sources = registry.list_all_skill_sources() - assert sources['built-in'] == ['a_skill'] + assert sources["built-in"] == ["a_skill"] def test_builtin_skips_dirs_without_skill_py(self) -> None: """Directories that lack skill.py are excluded.""" @@ -705,11 +766,11 @@ def test_builtin_skips_dirs_without_skill_py(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = entries - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir sources = registry.list_all_skill_sources() - assert sources['built-in'] == ['valid_skill'] + assert sources["built-in"] == ["valid_skill"] def test_external_paths_listed(self) -> None: """Skills from external directories appear under external_paths.""" @@ -724,11 +785,11 @@ def test_external_paths_listed(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = [] - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir sources = registry.list_all_skill_sources() - assert sources['external_paths'] == ['custom_skill'] + assert sources["external_paths"] == ["custom_skill"] def test_external_path_not_exists_skipped(self) -> None: """External paths that don't exist are silently skipped.""" @@ -741,11 +802,11 @@ def test_external_path_not_exists_skipped(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = [] - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir sources = registry.list_all_skill_sources() - assert sources['external_paths'] == [] + assert sources["external_paths"] == [] def test_registered_skills_not_in_builtin(self) -> None: """Registered skills that are NOT in the built-in list go under 'registered'.""" @@ -755,11 +816,11 @@ def test_registered_skills_not_in_builtin(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = [] - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir sources = registry.list_all_skill_sources() - assert 'mock_skill' in sources['registered'] + assert "mock_skill" in sources["registered"] def test_registered_skill_also_builtin_not_duplicated(self) -> None: """A registered skill whose name matches a built-in should NOT appear in 'registered'.""" @@ -771,22 +832,25 @@ def test_registered_skill_also_builtin_not_duplicated(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = entries - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir sources = registry.list_all_skill_sources() - assert 'mock_skill' in sources['built-in'] - assert 'mock_skill' not in sources['registered'] + assert "mock_skill" in sources["built-in"] + assert "mock_skill" not in sources["registered"] # --------------------------------------------------------------------------- # TestLoadSkillFromPathVariants # --------------------------------------------------------------------------- + class TestLoadSkillFromPathVariants: """Test loading skills from paths - edge cases and variants.""" - def _make_base_path(self, name: str = "skills", skill_file_exists: bool = True) -> Mock: + def _make_base_path( + self, name: str = "skills", skill_file_exists: bool = True + ) -> Mock: """Helper: create mock base_path where base_path/skill_name/skill.py exists.""" mock_base_path = Mock() mock_base_path.name = name @@ -803,7 +867,10 @@ def test_skill_file_not_exists_returns_none(self) -> None: base = self._make_base_path(skill_file_exists=False) assert registry._load_skill_from_path("anything", base) is None - @patch('signalwire.skills.registry.importlib.util.spec_from_file_location', return_value=None) + @patch( + "signalwire.skills.registry.importlib.util.spec_from_file_location", + return_value=None, + ) def test_spec_is_none_returns_none(self, _mock_spec: MagicMock) -> None: """When spec_from_file_location returns None, exception is caught and None returned.""" registry = SkillRegistry() @@ -811,10 +878,12 @@ def test_spec_is_none_returns_none(self, _mock_spec: MagicMock) -> None: result = registry._load_skill_from_path("test_skill", base) assert result is None - @patch('signalwire.skills.registry.importlib.util.spec_from_file_location') - @patch('signalwire.skills.registry.importlib.util.module_from_spec') - @patch('signalwire.skills.registry.inspect.getmembers') - def test_no_matching_skillbase_subclass(self, mock_members: MagicMock, mock_mod: MagicMock, mock_spec: MagicMock) -> None: + @patch("signalwire.skills.registry.importlib.util.spec_from_file_location") + @patch("signalwire.skills.registry.importlib.util.module_from_spec") + @patch("signalwire.skills.registry.inspect.getmembers") + def test_no_matching_skillbase_subclass( + self, mock_members: MagicMock, mock_mod: MagicMock, mock_spec: MagicMock + ) -> None: """When module has no SkillBase subclass with matching name, logs warning and returns None.""" registry = SkillRegistry() base = self._make_base_path() @@ -826,16 +895,18 @@ def test_no_matching_skillbase_subclass(self, mock_members: MagicMock, mock_mod: # Return a regular class, not a SkillBase subclass mock_members.return_value = [("Foo", str), ("Bar", int)] - with patch.object(registry.logger, 'warning') as warn: + with patch.object(registry.logger, "warning") as warn: result = registry._load_skill_from_path("test_skill", base) assert result is None warn.assert_called_once() assert "No skill class found" in warn.call_args[0][0] - @patch('signalwire.skills.registry.importlib.util.spec_from_file_location') - @patch('signalwire.skills.registry.importlib.util.module_from_spec') - @patch('signalwire.skills.registry.inspect.getmembers') - def test_class_with_wrong_skill_name_skipped(self, mock_members: MagicMock, mock_mod: MagicMock, mock_spec: MagicMock) -> None: + @patch("signalwire.skills.registry.importlib.util.spec_from_file_location") + @patch("signalwire.skills.registry.importlib.util.module_from_spec") + @patch("signalwire.skills.registry.inspect.getmembers") + def test_class_with_wrong_skill_name_skipped( + self, mock_members: MagicMock, mock_mod: MagicMock, mock_spec: MagicMock + ) -> None: """A SkillBase subclass with a different SKILL_NAME is not loaded.""" registry = SkillRegistry() base = self._make_base_path() @@ -850,10 +921,12 @@ def test_class_with_wrong_skill_name_skipped(self, mock_members: MagicMock, mock result = registry._load_skill_from_path("other", base) assert result is None - @patch('signalwire.skills.registry.importlib.util.spec_from_file_location') - @patch('signalwire.skills.registry.importlib.util.module_from_spec') - @patch('signalwire.skills.registry.inspect.getmembers') - def test_first_matching_class_wins(self, mock_members: MagicMock, mock_mod: MagicMock, mock_spec: MagicMock) -> None: + @patch("signalwire.skills.registry.importlib.util.spec_from_file_location") + @patch("signalwire.skills.registry.importlib.util.module_from_spec") + @patch("signalwire.skills.registry.inspect.getmembers") + def test_first_matching_class_wins( + self, mock_members: MagicMock, mock_mod: MagicMock, mock_spec: MagicMock + ) -> None: """When multiple SkillBase subclasses match, the first one found is returned.""" registry = SkillRegistry() base = self._make_base_path() @@ -868,27 +941,34 @@ class DuplicateMockSkill(SkillBase): SKILL_NAME = "mock_skill" SKILL_DESCRIPTION = "Dup" SUPPORTS_MULTIPLE_INSTANCES = False + @classmethod def get_parameter_schema(cls) -> dict[str, dict[str, Any]]: schema: dict[str, dict[str, Any]] = super().get_parameter_schema() schema["x"] = {"type": "string", "description": "x"} return schema - def setup(self) -> bool: return True - def register_tools(self) -> None: pass + + def setup(self) -> bool: + return True + + def register_tools(self) -> None: + pass mock_members.return_value = [ ("MockSkill", MockSkill), ("DuplicateMockSkill", DuplicateMockSkill), ] - with patch.object(registry, 'register_skill'): + with patch.object(registry, "register_skill"): result = registry._load_skill_from_path("mock_skill", base) assert result is MockSkill - @patch('signalwire.skills.registry.importlib.util.spec_from_file_location') - @patch('signalwire.skills.registry.importlib.util.module_from_spec') - @patch('signalwire.skills.registry.inspect.getmembers') - def test_module_added_to_sys_modules(self, mock_members: MagicMock, mock_mod: MagicMock, mock_spec: MagicMock) -> None: + @patch("signalwire.skills.registry.importlib.util.spec_from_file_location") + @patch("signalwire.skills.registry.importlib.util.module_from_spec") + @patch("signalwire.skills.registry.inspect.getmembers") + def test_module_added_to_sys_modules( + self, mock_members: MagicMock, mock_mod: MagicMock, mock_spec: MagicMock + ) -> None: """The loaded module is inserted into sys.modules.""" registry = SkillRegistry() base = self._make_base_path() @@ -902,16 +982,18 @@ def test_module_added_to_sys_modules(self, mock_members: MagicMock, mock_mod: Ma module_name = f"signalwire_agents_external.skills.mock_skill.skill" # noqa: F541 try: - with patch.object(registry, 'register_skill'): + with patch.object(registry, "register_skill"): registry._load_skill_from_path("mock_skill", base) assert module_name in sys.modules assert sys.modules[module_name] is fake_module finally: sys.modules.pop(module_name, None) - @patch('signalwire.skills.registry.importlib.util.spec_from_file_location') - @patch('signalwire.skills.registry.importlib.util.module_from_spec') - def test_exec_module_exception_returns_none(self, mock_mod: MagicMock, mock_spec: MagicMock) -> None: + @patch("signalwire.skills.registry.importlib.util.spec_from_file_location") + @patch("signalwire.skills.registry.importlib.util.module_from_spec") + def test_exec_module_exception_returns_none( + self, mock_mod: MagicMock, mock_spec: MagicMock + ) -> None: """If exec_module raises, the error is caught and None is returned.""" registry = SkillRegistry() base = self._make_base_path() @@ -924,10 +1006,12 @@ def test_exec_module_exception_returns_none(self, mock_mod: MagicMock, mock_spec result = registry._load_skill_from_path("bad_skill", base) assert result is None - @patch('signalwire.skills.registry.importlib.util.spec_from_file_location') - @patch('signalwire.skills.registry.importlib.util.module_from_spec') - @patch('signalwire.skills.registry.inspect.getmembers') - def test_skillbase_itself_is_skipped(self, mock_members: MagicMock, mock_mod: MagicMock, mock_spec: MagicMock) -> None: + @patch("signalwire.skills.registry.importlib.util.spec_from_file_location") + @patch("signalwire.skills.registry.importlib.util.module_from_spec") + @patch("signalwire.skills.registry.inspect.getmembers") + def test_skillbase_itself_is_skipped( + self, mock_members: MagicMock, mock_mod: MagicMock, mock_spec: MagicMock + ) -> None: """SkillBase class itself (obj == SkillBase) is skipped during scanning.""" registry = SkillRegistry() base = self._make_base_path() @@ -941,10 +1025,12 @@ def test_skillbase_itself_is_skipped(self, mock_members: MagicMock, mock_mod: Ma result = registry._load_skill_from_path("SkillBase", base) assert result is None - @patch('signalwire.skills.registry.importlib.util.spec_from_file_location') - @patch('signalwire.skills.registry.importlib.util.module_from_spec') - @patch('signalwire.skills.registry.inspect.getmembers') - def test_class_without_skill_name_attr_skipped(self, mock_members: MagicMock, mock_mod: MagicMock, mock_spec: MagicMock) -> None: + @patch("signalwire.skills.registry.importlib.util.spec_from_file_location") + @patch("signalwire.skills.registry.importlib.util.module_from_spec") + @patch("signalwire.skills.registry.inspect.getmembers") + def test_class_without_skill_name_attr_skipped( + self, mock_members: MagicMock, mock_mod: MagicMock, mock_spec: MagicMock + ) -> None: """A class that inherits SkillBase but has no SKILL_NAME attribute is skipped.""" registry = SkillRegistry() base = self._make_base_path() @@ -955,10 +1041,14 @@ def test_class_without_skill_name_attr_skipped(self, mock_members: MagicMock, mo mock_mod.return_value = Mock() # Create a mock class that looks like SkillBase subclass but no SKILL_NAME - fake_cls = type('FakeSkill', (SkillBase,), { - 'setup': lambda self: None, - 'register_tools': lambda self: None, - }) + fake_cls = type( + "FakeSkill", + (SkillBase,), + { + "setup": lambda self: None, + "register_tools": lambda self: None, + }, + ) # SkillBase sets SKILL_NAME = None, and the code checks hasattr + obj.SKILL_NAME == skill_name mock_members.return_value = [("FakeSkill", fake_cls)] @@ -971,10 +1061,13 @@ def test_class_without_skill_name_attr_skipped(self, mock_members: MagicMock, mo # TestDirectoryScanning # --------------------------------------------------------------------------- + class TestDirectoryScanning: """Test scanning directories for skills - various scenarios.""" - def _make_dir_entry(self, name: str, has_skill_py: bool = True, is_dir: bool = True) -> Mock: + def _make_dir_entry( + self, name: str, has_skill_py: bool = True, is_dir: bool = True + ) -> Mock: entry = Mock() entry.is_dir.return_value = is_dir entry.name = name @@ -1022,15 +1115,15 @@ def test_get_all_skills_schema_includes_registered(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = [] - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir - with patch.object(registry, '_load_entry_points'): + with patch.object(registry, "_load_entry_points"): schema = registry.get_all_skills_schema() - assert 'mock_skill' in schema - assert schema['mock_skill']['source'] == 'registered' - assert schema['mock_skill']['name'] == 'mock_skill' - assert schema['mock_skill']['description'] == 'A mock skill for testing' + assert "mock_skill" in schema + assert schema["mock_skill"]["source"] == "registered" + assert schema["mock_skill"]["name"] == "mock_skill" + assert schema["mock_skill"]["description"] == "A mock skill for testing" def test_get_all_skills_schema_builtin_scan(self) -> None: """Built-in skills are scanned and added with source='built-in'.""" @@ -1040,14 +1133,16 @@ def test_get_all_skills_schema_builtin_scan(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = entries - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir - with patch.object(registry, '_load_entry_points'): - with patch.object(registry, '_load_skill_on_demand', return_value=MockSkill): - schema = registry.get_all_skills_schema() + with ( + patch.object(registry, "_load_entry_points"), + patch.object(registry, "_load_skill_on_demand", return_value=MockSkill), + ): + schema = registry.get_all_skills_schema() - assert 'mock_skill' in schema - assert schema['mock_skill']['source'] == 'built-in' + assert "mock_skill" in schema + assert schema["mock_skill"]["source"] == "built-in" def test_get_all_skills_schema_external_scan(self, tmp_path: Path) -> None: """External path skills appear with source='external'.""" @@ -1062,14 +1157,16 @@ def test_get_all_skills_schema_external_scan(self, tmp_path: Path) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = [] - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir - with patch.object(registry, '_load_entry_points'): - with patch.object(registry, '_load_skill_on_demand', return_value=MockSkill): - schema = registry.get_all_skills_schema() + with ( + patch.object(registry, "_load_entry_points"), + patch.object(registry, "_load_skill_on_demand", return_value=MockSkill), + ): + schema = registry.get_all_skills_schema() - assert 'mock_skill' in schema - assert schema['mock_skill']['source'] == 'external' + assert "mock_skill" in schema + assert schema["mock_skill"]["source"] == "external" def test_get_all_skills_schema_env_paths(self) -> None: """Skills from SIGNALWIRE_SKILL_PATHS env var are scanned.""" @@ -1089,20 +1186,22 @@ def test_get_all_skills_schema_env_paths(self) -> None: mock_file_path.parent = mock_skills_dir def path_factory(x: str) -> Mock: - if x == '/fake/env/path': + if x == "/fake/env/path": return mock_env_path # For __file__ and anything else, return something with .parent result = Mock() result.parent = mock_skills_dir return result - with patch('signalwire.skills.registry.Path', side_effect=path_factory): - with patch.object(registry, '_load_entry_points'): - with patch.object(registry, '_load_skill_on_demand', return_value=MockSkill): - with patch.dict('os.environ', {'SIGNALWIRE_SKILL_PATHS': '/fake/env/path'}): - schema = registry.get_all_skills_schema() + with ( + patch("signalwire.skills.registry.Path", side_effect=path_factory), + patch.object(registry, "_load_entry_points"), + patch.object(registry, "_load_skill_on_demand", return_value=MockSkill), + patch.dict("os.environ", {"SIGNALWIRE_SKILL_PATHS": "/fake/env/path"}), + ): + schema = registry.get_all_skills_schema() - assert 'mock_skill' in schema + assert "mock_skill" in schema def test_get_all_skills_schema_skips_already_in_schema(self) -> None: """Skills already present in schema from an earlier source are not overwritten.""" @@ -1114,13 +1213,13 @@ def test_get_all_skills_schema_skips_already_in_schema(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = entries - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir - with patch.object(registry, '_load_entry_points'): + with patch.object(registry, "_load_entry_points"): schema = registry.get_all_skills_schema() # Should be 'registered', not overwritten to 'built-in' - assert schema['mock_skill']['source'] == 'registered' + assert schema["mock_skill"]["source"] == "registered" def test_get_all_skills_schema_handles_load_failure(self) -> None: """When _load_skill_on_demand raises, error is logged and skill skipped.""" @@ -1130,14 +1229,18 @@ def test_get_all_skills_schema_handles_load_failure(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = entries - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir - with patch.object(registry, '_load_entry_points'): - with patch.object(registry, '_load_skill_on_demand', side_effect=RuntimeError("boom")): - with patch.object(registry.logger, 'error'): - schema = registry.get_all_skills_schema() + with ( + patch.object(registry, "_load_entry_points"), + patch.object( + registry, "_load_skill_on_demand", side_effect=RuntimeError("boom") + ), + patch.object(registry.logger, "error"), + ): + schema = registry.get_all_skills_schema() - assert 'bad_skill' not in schema + assert "bad_skill" not in schema def test_get_all_skills_schema_handles_none_from_load(self) -> None: """When _load_skill_on_demand returns None, the skill is simply skipped.""" @@ -1147,13 +1250,15 @@ def test_get_all_skills_schema_handles_none_from_load(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = entries - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir - with patch.object(registry, '_load_entry_points'): - with patch.object(registry, '_load_skill_on_demand', return_value=None): - schema = registry.get_all_skills_schema() + with ( + patch.object(registry, "_load_entry_points"), + patch.object(registry, "_load_skill_on_demand", return_value=None), + ): + schema = registry.get_all_skills_schema() - assert 'missing_skill' not in schema + assert "missing_skill" not in schema def test_get_all_skills_schema_skill_without_get_parameter_schema(self) -> None: """If skill_class lacks get_parameter_schema, empty dict is used for parameters.""" @@ -1173,14 +1278,18 @@ def test_get_all_skills_schema_skill_without_get_parameter_schema(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = entries - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir - with patch.object(registry, '_load_entry_points'): - with patch.object(registry, '_load_skill_on_demand', return_value=fake_skill): - schema = registry.get_all_skills_schema() + with ( + patch.object(registry, "_load_entry_points"), + patch.object( + registry, "_load_skill_on_demand", return_value=fake_skill + ), + ): + schema = registry.get_all_skills_schema() - assert 'attr_err_skill' in schema - assert schema['attr_err_skill']['parameters'] == {} + assert "attr_err_skill" in schema + assert schema["attr_err_skill"]["parameters"] == {} def test_get_all_skills_schema_external_path_not_exists(self) -> None: """External paths that don't exist are silently skipped in schema scan.""" @@ -1193,9 +1302,9 @@ def test_get_all_skills_schema_external_path_not_exists(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = [] - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir - with patch.object(registry, '_load_entry_points'): + with patch.object(registry, "_load_entry_points"): schema = registry.get_all_skills_schema() assert schema == {} @@ -1208,7 +1317,7 @@ def test_list_skills_skips_dirs_without_skill_py(self) -> None: mock_skills_dir = Mock() mock_skills_dir.iterdir.return_value = entries - with patch('signalwire.skills.registry.Path') as MockPath: + with patch("signalwire.skills.registry.Path") as MockPath: MockPath.return_value.parent = mock_skills_dir skills = registry.list_skills() @@ -1225,6 +1334,7 @@ def test_deprecated_load_skill_from_directory_noop(self) -> None: # TestEntryPointLoading # --------------------------------------------------------------------------- + class TestEntryPointLoading: """Test loading skills from entry points.""" @@ -1240,10 +1350,12 @@ def test_valid_entry_point_with_select(self) -> None: mock_all_eps.select.return_value = [mock_ep] # hasattr(mock_all_eps, 'select') -> True by default with Mock - with patch('importlib.metadata.entry_points', return_value=mock_all_eps): - with patch.object(registry, 'register_skill') as mock_reg: - registry._load_entry_points() - mock_reg.assert_called_once_with(MockSkill) + with ( + patch("importlib.metadata.entry_points", return_value=mock_all_eps), + patch.object(registry, "register_skill") as mock_reg, + ): + registry._load_entry_points() + mock_reg.assert_called_once_with(MockSkill) assert registry._entry_points_loaded is True @@ -1256,12 +1368,14 @@ def test_valid_entry_point_dict_api(self) -> None: mock_ep.load.return_value = MockSkill # Create object without 'select' attribute - mock_all_eps = {'signalwire.skills': [mock_ep]} + mock_all_eps = {"signalwire.skills": [mock_ep]} - with patch('importlib.metadata.entry_points', return_value=mock_all_eps): - with patch.object(registry, 'register_skill') as mock_reg: - registry._load_entry_points() - mock_reg.assert_called_once_with(MockSkill) + with ( + patch("importlib.metadata.entry_points", return_value=mock_all_eps), + patch.object(registry, "register_skill") as mock_reg, + ): + registry._load_entry_points() + mock_reg.assert_called_once_with(MockSkill) def test_broken_entry_point_load_fails(self) -> None: """If entry_point.load() raises, error is logged and loading continues.""" @@ -1274,11 +1388,13 @@ def test_broken_entry_point_load_fails(self) -> None: mock_all_eps = Mock() mock_all_eps.select.return_value = [mock_ep] - with patch('importlib.metadata.entry_points', return_value=mock_all_eps): - with patch.object(registry.logger, 'error') as mock_err: - registry._load_entry_points() - mock_err.assert_called_once() - assert "Failed to load skill from entry point" in mock_err.call_args[0][0] + with ( + patch("importlib.metadata.entry_points", return_value=mock_all_eps), + patch.object(registry.logger, "error") as mock_err, + ): + registry._load_entry_points() + mock_err.assert_called_once() + assert "Failed to load skill from entry point" in mock_err.call_args[0][0] def test_non_skillbase_entry_point(self) -> None: """Entry points that load a non-SkillBase class are warned about.""" @@ -1291,18 +1407,20 @@ def test_non_skillbase_entry_point(self) -> None: mock_all_eps = Mock() mock_all_eps.select.return_value = [mock_ep] - with patch('importlib.metadata.entry_points', return_value=mock_all_eps): - with patch.object(registry.logger, 'warning') as mock_warn: - registry._load_entry_points() - mock_warn.assert_called_once() - assert "does not provide a SkillBase subclass" in mock_warn.call_args[0][0] + with ( + patch("importlib.metadata.entry_points", return_value=mock_all_eps), + patch.object(registry.logger, "warning") as mock_warn, + ): + registry._load_entry_points() + mock_warn.assert_called_once() + assert "does not provide a SkillBase subclass" in mock_warn.call_args[0][0] def test_entry_points_loaded_flag_prevents_reload(self) -> None: """Once _entry_points_loaded is True, the method returns immediately.""" registry = SkillRegistry() registry._entry_points_loaded = True - with patch('importlib.metadata.entry_points') as mock_ep: + with patch("importlib.metadata.entry_points") as mock_ep: registry._load_entry_points() mock_ep.assert_not_called() @@ -1310,11 +1428,16 @@ def test_entry_points_overall_exception(self) -> None: """If entry_points() itself raises, the error is caught gracefully.""" registry = SkillRegistry() - with patch('importlib.metadata.entry_points', side_effect=Exception("metadata broke")): - with patch.object(registry.logger, 'debug') as mock_debug: - registry._load_entry_points() - mock_debug.assert_called_once() - assert "Entry point loading failed" in mock_debug.call_args[0][0] + with ( + patch( + "importlib.metadata.entry_points", + side_effect=Exception("metadata broke"), + ), + patch.object(registry.logger, "debug") as mock_debug, + ): + registry._load_entry_points() + mock_debug.assert_called_once() + assert "Entry point loading failed" in mock_debug.call_args[0][0] # Flag should still be set to prevent retries assert registry._entry_points_loaded is True @@ -1334,10 +1457,12 @@ def test_multiple_entry_points_loaded(self) -> None: mock_all_eps = Mock() mock_all_eps.select.return_value = [ep1, ep2] - with patch('importlib.metadata.entry_points', return_value=mock_all_eps): - with patch.object(registry, 'register_skill') as mock_reg: - registry._load_entry_points() - assert mock_reg.call_count == 2 + with ( + patch("importlib.metadata.entry_points", return_value=mock_all_eps), + patch.object(registry, "register_skill") as mock_reg, + ): + registry._load_entry_points() + assert mock_reg.call_count == 2 def test_entry_point_register_skill_failure(self) -> None: """If register_skill raises for an entry point, error is logged.""" @@ -1350,11 +1475,15 @@ def test_entry_point_register_skill_failure(self) -> None: mock_all_eps = Mock() mock_all_eps.select.return_value = [mock_ep] - with patch('importlib.metadata.entry_points', return_value=mock_all_eps): - with patch.object(registry, 'register_skill', side_effect=ValueError("bad skill")): - with patch.object(registry.logger, 'error') as mock_err: - registry._load_entry_points() - mock_err.assert_called_once() + with ( + patch("importlib.metadata.entry_points", return_value=mock_all_eps), + patch.object( + registry, "register_skill", side_effect=ValueError("bad skill") + ), + patch.object(registry.logger, "error") as mock_err, + ): + registry._load_entry_points() + mock_err.assert_called_once() # -- _load_skill_on_demand integration with entry points -- @@ -1374,7 +1503,7 @@ def fake_load_eps() -> None: registry._entry_points_loaded = True registry._skills["dynamic"] = MockSkill - with patch.object(registry, '_load_entry_points', side_effect=fake_load_eps): + with patch.object(registry, "_load_entry_points", side_effect=fake_load_eps): result = registry._load_skill_on_demand("dynamic") assert result is MockSkill @@ -1393,9 +1522,13 @@ def fake_load_from_path(name: str, path: Path) -> type[SkillBase] | None: return MockSkill return None - with patch.object(registry, '_load_entry_points'): - with patch.object(registry, '_load_skill_from_path', side_effect=fake_load_from_path): - result = registry._load_skill_on_demand("mock_skill") + with ( + patch.object(registry, "_load_entry_points"), + patch.object( + registry, "_load_skill_from_path", side_effect=fake_load_from_path + ), + ): + result = registry._load_skill_on_demand("mock_skill") assert result is MockSkill # Should have tried built-in first, then external @@ -1408,16 +1541,26 @@ def test_load_on_demand_searches_env_paths(self) -> None: call_log = [] + # The product turns each entry into Path(path_str), so match on the Path + # itself instead of a str() spelling of it: str(Path("/env/skills")) is + # "\env\skills" on Windows, so the literal comparison never matched + # there and the env-var search looked broken when it was not. + env_path = Path("/env/skills") + def fake_load_from_path(name: str, path: Path) -> type[SkillBase] | None: call_log.append((name, path)) - if str(path) == "/env/skills": + if path == env_path: return MockSkill return None - with patch.object(registry, '_load_entry_points'): - with patch.object(registry, '_load_skill_from_path', side_effect=fake_load_from_path): - with patch.dict('os.environ', {'SIGNALWIRE_SKILL_PATHS': '/env/skills'}): - result = registry._load_skill_on_demand("mock_skill") + with ( + patch.object(registry, "_load_entry_points"), + patch.object( + registry, "_load_skill_from_path", side_effect=fake_load_from_path + ), + patch.dict("os.environ", {"SIGNALWIRE_SKILL_PATHS": str(env_path)}), + ): + result = registry._load_skill_on_demand("mock_skill") assert result is MockSkill @@ -1425,27 +1568,33 @@ def test_load_on_demand_env_path_empty_string_skipped(self) -> None: """Empty strings in SIGNALWIRE_SKILL_PATHS are skipped.""" registry = SkillRegistry() - with patch.object(registry, '_load_entry_points'): - with patch.object(registry, '_load_skill_from_path', return_value=None) as mock_load: - with patch.dict('os.environ', {'SIGNALWIRE_SKILL_PATHS': ''}): - result = registry._load_skill_on_demand("skill_x") + with ( + patch.object(registry, "_load_entry_points"), + patch.object( + registry, "_load_skill_from_path", return_value=None + ) as mock_load, + patch.dict("os.environ", {"SIGNALWIRE_SKILL_PATHS": ""}), + ): + result = registry._load_skill_on_demand("skill_x") assert result is None # _load_skill_from_path should only be called for the built-in dir, not for empty string for call in mock_load.call_args_list: assert call[0][0] == "skill_x" # First arg is skill name # Second arg should not be Path('') - assert str(call[0][1]) != '' + assert str(call[0][1]) != "" def test_load_on_demand_not_found_returns_none(self) -> None: """When skill is not found anywhere, None is returned with debug log.""" registry = SkillRegistry() - with patch.object(registry, '_load_entry_points'): - with patch.object(registry, '_load_skill_from_path', return_value=None): - with patch.dict('os.environ', {'SIGNALWIRE_SKILL_PATHS': ''}): - with patch.object(registry.logger, 'debug') as mock_debug: - result = registry._load_skill_on_demand("nowhere_skill") + with ( + patch.object(registry, "_load_entry_points"), + patch.object(registry, "_load_skill_from_path", return_value=None), + patch.dict("os.environ", {"SIGNALWIRE_SKILL_PATHS": ""}), + patch.object(registry.logger, "debug") as mock_debug, + ): + result = registry._load_skill_on_demand("nowhere_skill") assert result is None mock_debug.assert_called_once() @@ -1456,6 +1605,7 @@ def test_load_on_demand_not_found_returns_none(self) -> None: # TestRegisterSkillValidation # --------------------------------------------------------------------------- + class TestRegisterSkillValidation: """Test register_skill validation edge cases.""" @@ -1478,15 +1628,25 @@ def test_register_skill_no_get_parameter_schema_raises(self) -> None: class BadSkill(SkillBase): SKILL_NAME = "bad" SKILL_DESCRIPTION = "bad" - def setup(self) -> bool: return True - def register_tools(self) -> None: pass + + def setup(self) -> bool: + return True + + def register_tools(self) -> None: + pass # Delete get_parameter_schema to simulate missing method # Since SkillBase has it, we need to make it not callable - with patch.object(BadSkill, 'get_parameter_schema', new_callable=lambda: property(lambda self: None)): + with ( + patch.object( + BadSkill, + "get_parameter_schema", + new_callable=lambda: property(lambda self: None), + ), # The hasattr check or callable check will fail - with pytest.raises(ValueError): - registry.register_skill(BadSkill) + pytest.raises(ValueError), + ): + registry.register_skill(BadSkill) def test_register_skill_schema_returns_non_dict(self) -> None: """Skill whose get_parameter_schema returns non-dict raises ValueError.""" @@ -1495,10 +1655,17 @@ def test_register_skill_schema_returns_non_dict(self) -> None: class BadSchemaSkill(SkillBase): SKILL_NAME = "bad_schema" SKILL_DESCRIPTION = "bad schema" - def setup(self) -> bool: return True - def register_tools(self) -> None: pass + + def setup(self) -> bool: + return True + + def register_tools(self) -> None: + pass + @classmethod - def get_parameter_schema(cls) -> Any: # deliberately returns non-dict to test validation + def get_parameter_schema( + cls, + ) -> Any: # deliberately returns non-dict to test validation return "not a dict" with pytest.raises(ValueError, match="must return a dictionary"): @@ -1511,8 +1678,13 @@ def test_register_skill_schema_returns_empty_dict(self) -> None: class EmptySchemaSkill(SkillBase): SKILL_NAME = "empty_schema" SKILL_DESCRIPTION = "empty schema" - def setup(self) -> bool: return True - def register_tools(self) -> None: pass + + def setup(self) -> bool: + return True + + def register_tools(self) -> None: + pass + @classmethod def get_parameter_schema(cls) -> dict[str, dict[str, Any]]: return {} @@ -1527,11 +1699,16 @@ def test_register_skill_schema_exception(self) -> None: class ExcSchemaSkill(SkillBase): SKILL_NAME = "exc_schema" SKILL_DESCRIPTION = "exc schema" - def setup(self) -> bool: return True - def register_tools(self) -> None: pass + + def setup(self) -> bool: + return True + + def register_tools(self) -> None: + pass + @classmethod def get_parameter_schema(cls) -> dict[str, dict[str, Any]]: raise RuntimeError("boom") with pytest.raises(ValueError, match="failed"): - registry.register_skill(ExcSchemaSkill) \ No newline at end of file + registry.register_skill(ExcSchemaSkill) diff --git a/tests/unit/skills/test_skip_prompt_guard.py b/tests/unit/skills/test_skip_prompt_guard.py new file mode 100644 index 00000000..240a5af2 --- /dev/null +++ b/tests/unit/skills/test_skip_prompt_guard.py @@ -0,0 +1,313 @@ +"""Regression: the ``skip_prompt`` guard must be unbypassable. + +``SkillBase.get_prompt_sections()`` is the guard-bearing entry point: it returns +an empty list when ``params["skip_prompt"]`` is set, and otherwise delegates to +the protected ``_get_prompt_sections()`` hook. Skills override the HOOK, never +the public method — overriding the public method silently disables the guard +for that skill. + +This was live: 11 of the 13 shipped skill files overrode the public method, so +``JokeSkill(skip_prompt=True).get_prompt_sections()`` returned 1 section instead +of 0. Three layers of coverage here: + +1. a structural sweep asserting no shipped skill class overrides the public + method (catches a NEW skill that reintroduces the bypass), +2. a behavioural check on a representative skill that DOES emit sections, and +3. a registry-parametrized sweep over EVERY discovered skill class asserting + both halves of the contract — ``skip_prompt`` suppresses all sections, AND + (the load-bearing inverse) each skill returns a non-empty list when + ``skip_prompt`` is unset. The inverse half is what catches a skill whose + hook returns ``[]`` for the wrong reason; see + ``TestEverySkillHonoursSkipPrompt``. +""" + +from __future__ import annotations + +import contextlib +import importlib +import pkgutil +from collections.abc import Iterator +from pathlib import Path +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +import signalwire.skills as skills_pkg +from signalwire.core.skill_base import SkillBase +from signalwire.skills.joke.skill import JokeSkill +from signalwire.skills.math.skill import MathSkill + + +def _iter_skill_modules() -> list[str]: + """Every shipped skill module (including non-``skill.py`` variants).""" + names: list[str] = [] + for mod in pkgutil.iter_modules(skills_pkg.__path__): + if not mod.ispkg: + continue + pkg = importlib.import_module(f"signalwire.skills.{mod.name}") + names.extend( + f"signalwire.skills.{mod.name}.{sub.name}" + for sub in pkgutil.iter_modules(pkg.__path__) + if not sub.ispkg + ) + return names + + +def _iter_skill_classes() -> list[type[SkillBase]]: + """All shipped SkillBase subclasses. + + Import failures are NOT swallowed: a silently skipped module would shrink + the sweep below and let a bypassing skill through unnoticed. + """ + classes: list[type[SkillBase]] = [] + for name in _iter_skill_modules(): + module = importlib.import_module(name) + classes.extend( + obj + for obj in vars(module).values() + if isinstance(obj, type) + and issubclass(obj, SkillBase) + and obj is not SkillBase + and obj.__module__ == name + ) + return classes + + +# --------------------------------------------------------------------------- +# Registry-driven parametrization +# +# The sweep below is parametrized over the DISCOVERED skill classes, not a +# hand-written list: a hand list silently omits a newly added skill, which is +# the blind spot this file exists to close. +# --------------------------------------------------------------------------- + +#: Smallest params that let each skill's ``setup()`` succeed offline. +#: Keyed by class name because two modules (``web_search.skill`` and its +#: ``skill_improved`` / ``skill_original`` variants) export the same class name. +MINIMAL_PARAMS: dict[str, dict[str, Any]] = { + "ApiNinjasTriviaSkill": {"api_key": "k"}, + "ClaudeSkillsSkill": {}, # skills_path injected by _make_skill (needs a real dir) + "DataSphereSkill": { + "space_name": "s", + "project_id": "p", + "token": "t", + "document_id": "d", + }, + "DataSphereServerlessSkill": { + "space_name": "s", + "project_id": "p", + "token": "t", + "document_id": "d", + }, + "DateTimeSkill": {}, + "GoogleMapsSkill": {"api_key": "k"}, + "InfoGathererSkill": { + "questions": [{"key_name": "name", "question_text": "What is your name?"}] + }, + "JokeSkill": {"api_key": "k"}, + "MathSkill": {}, + "MCPGatewaySkill": {"gateway_url": "http://gw.test", "auth_token": "t"}, + "NativeVectorSearchSkill": {}, + "PlayBackgroundFileSkill": { + "files": [{"key": "k", "description": "d", "url": "http://x.test/a.mp3"}] + }, + "SpiderSkill": {"api_key": "k"}, + "SWMLTransferSkill": {"transfers": {"sales": {"url": "sip:x@y", "message": "m"}}}, + "WeatherApiSkill": {"api_key": "k"}, + "WebSearchSkill": {"api_key": "k", "search_engine_id": "e"}, + "WikipediaSearchSkill": {}, +} + +#: Skills that legitimately contribute NO prompt section, with the reason. +#: The first four define no ``_get_prompt_sections`` override at all and inherit +#: the base's empty list; ``MCPGatewaySkill`` defines the hook but emits a +#: section only when ``services`` are configured, and the minimal params +#: configure none. Every entry here is EXEMPT from the non-empty assertion — +#: which is why the exemption list is asserted to be exactly this set, so a +#: skill cannot quietly join it. +NO_SECTION_BY_DESIGN: dict[str, str] = { + "ApiNinjasTriviaSkill": "defines no _get_prompt_sections override", + "PlayBackgroundFileSkill": "defines no _get_prompt_sections override", + "SpiderSkill": "defines no _get_prompt_sections override", + "WeatherApiSkill": "defines no _get_prompt_sections override", + "MCPGatewaySkill": "emits a section only when 'services' are configured", +} + + +@contextlib.contextmanager +def _offline(skill_cls: type[SkillBase]) -> Iterator[None]: + """Neutralize the only network/DNS calls any skill makes during ``setup()``. + + ``MCPGatewaySkill.setup()`` GETs ``/health`` and runs the + gateway URL through SSRF validation, which does a DNS lookup. Both are + stubbed so the sweep stays offline and deterministic. No other skill needs + stubbing — all the rest complete ``setup()`` with local params alone. + """ + if skill_cls.__name__ != "MCPGatewaySkill": + yield + return + with ( + patch(f"{skill_cls.__module__}.requests.get") as get, + patch("signalwire.utils.url_validator.validate_url", return_value=True), + ): + get.return_value = Mock(raise_for_status=Mock(return_value=None)) + yield + + +def _make_skill(skill_cls: type[SkillBase], tmp_path: Path, **extra: Any) -> SkillBase: + """Construct ``skill_cls`` with its minimal params plus ``extra``.""" + params = dict(MINIMAL_PARAMS[skill_cls.__name__]) + if skill_cls.__name__ == "ClaudeSkillsSkill": + # Needs a real directory containing at least one SKILL.md. + skills_dir = tmp_path / "claude_skills" + (skills_dir / "demo").mkdir(parents=True, exist_ok=True) + (skills_dir / "demo" / "SKILL.md").write_text( + "---\nname: demo\ndescription: A demo skill\n---\n\n# Demo\n" + ) + params["skills_path"] = str(skills_dir) + params.update(extra) + return skill_cls(agent=Mock(), params=params) + + +_SKILL_CLASSES = _iter_skill_classes() +_SKILL_IDS = [ + f"{c.__module__.split('.')[-2]}.{c.__module__.split('.')[-1]}" + for c in _SKILL_CLASSES +] + + +class TestEverySkillHonoursSkipPrompt: + """Registry-wide sweep of the ``skip_prompt`` contract. + + The suppression half alone is VACUOUS: a skill whose hook returns ``[]`` + for the wrong reason passes it trivially. That is exactly how + ``native_vector_search`` hid — its hook returned ``[]`` while its real + content sat in a push-style ``_add_prompt_section(agent)`` helper nothing + called, so the skill shipped contributing no prompt section at all and + every skip_prompt assertion about it passed. + + ``test_default_returns_a_section`` is therefore the load-bearing half: it + asserts each skill returns a NON-EMPTY list when ``skip_prompt`` is unset, + which is what catches the false pass. Skills that legitimately emit nothing + are listed in ``NO_SECTION_BY_DESIGN`` with a reason, and that list is + itself pinned by ``test_no_section_exemptions_are_exactly_as_recorded`` so a + regressing skill cannot quietly join it. + """ + + def test_every_skill_class_has_minimal_params(self) -> None: + """No skill may be silently absent from the parametrization.""" + assert _SKILL_CLASSES, "no skill classes discovered — the sweep is vacuous" + missing = sorted({c.__name__ for c in _SKILL_CLASSES} - set(MINIMAL_PARAMS)) + assert missing == [], ( + "these skill classes have no MINIMAL_PARAMS entry, so they are not " + f"covered by the skip_prompt sweep: {missing}" + ) + + def test_no_section_exemptions_are_exactly_as_recorded(self) -> None: + """Pin the exemption list so a regression cannot quietly join it.""" + known = {c.__name__ for c in _SKILL_CLASSES} + stale = sorted(set(NO_SECTION_BY_DESIGN) - known) + assert stale == [], f"NO_SECTION_BY_DESIGN names unknown skills: {stale}" + + @pytest.mark.parametrize("skill_cls", _SKILL_CLASSES, ids=_SKILL_IDS) + def test_skip_prompt_suppresses_all_sections( + self, skill_cls: type[SkillBase], tmp_path: Path + ) -> None: + with _offline(skill_cls): + skill = _make_skill(skill_cls, tmp_path, skip_prompt=True) + assert skill.setup() is True, f"{skill_cls.__name__}.setup() failed" + assert skill.get_prompt_sections() == [] + + @pytest.mark.parametrize("skill_cls", _SKILL_CLASSES, ids=_SKILL_IDS) + def test_default_returns_a_section( + self, skill_cls: type[SkillBase], tmp_path: Path + ) -> None: + """The INVERSE assertion — the half that catches a vacuous pass. + + Without this, a skill whose hook returns ``[]`` for the wrong reason + satisfies the suppression test and ships broken. + """ + with _offline(skill_cls): + skill = _make_skill(skill_cls, tmp_path) + assert skill.setup() is True, f"{skill_cls.__name__}.setup() failed" + sections = skill.get_prompt_sections() + + if skill_cls.__name__ in NO_SECTION_BY_DESIGN: + reason = NO_SECTION_BY_DESIGN[skill_cls.__name__] + assert sections == [], ( + f"{skill_cls.__name__} is recorded in NO_SECTION_BY_DESIGN " + f"({reason}) but returned {len(sections)} section(s); remove the " + "exemption" + ) + return + + assert sections, ( + f"{skill_cls.__name__}.get_prompt_sections() returned an empty list " + "with skip_prompt unset. Either the skill's content is stranded " + "outside the _get_prompt_sections() hook (the native_vector_search " + "defect), or it genuinely emits nothing — in which case add it to " + "NO_SECTION_BY_DESIGN with a reason." + ) + for section in sections: + assert section.get("title"), f"{skill_cls.__name__}: section without title" + + +class TestSkipPromptGuardIsUnbypassable: + def test_no_shipped_skill_overrides_the_public_method(self) -> None: + """Skills override ``_get_prompt_sections``, never ``get_prompt_sections``. + + An override of the public method bypasses the ``skip_prompt`` guard for + that skill, which is exactly the defect this file guards against. + """ + classes = _iter_skill_classes() + assert classes, "no skill classes discovered — the sweep would be vacuous" + offenders = [ + f"{cls.__module__}.{cls.__name__}" + for cls in classes + if "get_prompt_sections" in vars(cls) + ] + assert offenders == [], ( + "these skills override the PUBLIC get_prompt_sections(), which " + "bypasses the skip_prompt guard in SkillBase; override the " + f"protected _get_prompt_sections() hook instead: {offenders}" + ) + + def test_base_delegates_to_the_protected_hook(self) -> None: + class _Skill(SkillBase): + SKILL_NAME = "t" + SKILL_DESCRIPTION = "t" + + def setup(self) -> bool: + return True + + def register_tools(self) -> None: + pass + + def _get_prompt_sections(self) -> list[dict[str, Any]]: + return [{"title": "T", "body": "B"}] + + assert len(_Skill(agent=Mock(), params={}).get_prompt_sections()) == 1 + assert ( + _Skill(agent=Mock(), params={"skip_prompt": True}).get_prompt_sections() + == [] + ) + + @pytest.mark.parametrize( + ("factory", "expected_default"), + [ + (lambda p: JokeSkill(agent=Mock(), params={"api_key": "k", **p}), 1), + (lambda p: MathSkill(agent=Mock(), params=dict(p)), 1), + ], + ids=["joke", "math"], + ) + def test_real_skill_honours_skip_prompt( + self, factory: Any, expected_default: int + ) -> None: + default = factory({}) + default.setup() + assert len(default.get_prompt_sections()) == expected_default + + skipped = factory({"skip_prompt": True}) + skipped.setup() + assert skipped.get_prompt_sections() == [] diff --git a/tests/unit/skills/test_spider_skill.py b/tests/unit/skills/test_spider_skill.py index 17a88975..86f57d00 100644 --- a/tests/unit/skills/test_spider_skill.py +++ b/tests/unit/skills/test_spider_skill.py @@ -24,6 +24,7 @@ # Helpers for building a mock agent and mock HTTP responses # --------------------------------------------------------------------------- + def _make_mock_agent() -> Mock: """Create a mock agent with a define_tool method.""" agent = Mock() @@ -31,10 +32,12 @@ def _make_mock_agent() -> Mock: return agent -def _make_mock_response(content: bytes = b"

Hello world

", - url: str = "https://example.com", - status_code: int = 200, - text: str | None = None) -> Mock: +def _make_mock_response( + content: bytes = b"

Hello world

", + url: str = "https://example.com", + status_code: int = 200, + text: str | None = None, +) -> Mock: """Create a mock requests.Response.""" resp = Mock() resp.content = content @@ -49,6 +52,7 @@ def _make_mock_response(content: bytes = b"

Hello world

# Fixture: create SpiderSkill instances with mocked dependencies # --------------------------------------------------------------------------- + @pytest.fixture def mock_agent() -> Mock: return _make_mock_agent() @@ -63,8 +67,8 @@ def default_skill(mock_agent: Mock) -> "SpiderSkill": MockSession.return_value = mock_session from signalwire.skills.spider.skill import SpiderSkill - skill = SpiderSkill(mock_agent, {}) - return skill + + return SpiderSkill(mock_agent, {}) @pytest.fixture @@ -93,43 +97,54 @@ def custom_skill(mock_agent: Mock) -> "SpiderSkill": MockSession.return_value = mock_session from signalwire.skills.spider.skill import SpiderSkill - skill = SpiderSkill(mock_agent, params) - return skill + + return SpiderSkill(mock_agent, params) # =================================================================== # Class attributes # =================================================================== + class TestSpiderSkillClassAttributes: """Verify class-level constants.""" def test_skill_name(self) -> None: from signalwire.skills.spider.skill import SpiderSkill + assert SpiderSkill.SKILL_NAME == "spider" def test_skill_description(self) -> None: from signalwire.skills.spider.skill import SpiderSkill - assert SpiderSkill.SKILL_DESCRIPTION == "Fast web scraping and crawling capabilities" + + assert ( + SpiderSkill.SKILL_DESCRIPTION + == "Fast web scraping and crawling capabilities" + ) def test_skill_version(self) -> None: from signalwire.skills.spider.skill import SpiderSkill + assert SpiderSkill.SKILL_VERSION == "1.0.0" def test_required_packages(self) -> None: from signalwire.skills.spider.skill import SpiderSkill + assert "lxml" in SpiderSkill.REQUIRED_PACKAGES def test_required_env_vars_empty(self) -> None: from signalwire.skills.spider.skill import SpiderSkill + assert SpiderSkill.REQUIRED_ENV_VARS == [] def test_supports_multiple_instances(self) -> None: from signalwire.skills.spider.skill import SpiderSkill + assert SpiderSkill.SUPPORTS_MULTIPLE_INSTANCES is True def test_whitespace_regex_compiled(self) -> None: from signalwire.skills.spider.skill import SpiderSkill + assert isinstance(SpiderSkill.WHITESPACE_REGEX, re.Pattern) assert SpiderSkill.WHITESPACE_REGEX.sub(" ", " a b ") == " a b " @@ -138,28 +153,42 @@ def test_whitespace_regex_compiled(self) -> None: # get_parameter_schema # =================================================================== + class TestGetParameterSchema: """Verify the parameter schema returned by the class method.""" def test_returns_dict(self) -> None: from signalwire.skills.spider.skill import SpiderSkill + schema = SpiderSkill.get_parameter_schema() assert isinstance(schema, dict) def test_contains_expected_keys(self) -> None: from signalwire.skills.spider.skill import SpiderSkill + schema = SpiderSkill.get_parameter_schema() expected_keys = [ - "delay", "concurrent_requests", "timeout", "max_pages", - "max_depth", "extract_type", "max_text_length", "clean_text", - "selectors", "follow_patterns", "user_agent", "headers", - "follow_robots_txt", "cache_enabled", + "delay", + "concurrent_requests", + "timeout", + "max_pages", + "max_depth", + "extract_type", + "max_text_length", + "clean_text", + "selectors", + "follow_patterns", + "user_agent", + "headers", + "follow_robots_txt", + "cache_enabled", ] for key in expected_keys: assert key in schema, f"Missing key: {key}" def test_includes_base_schema_keys(self) -> None: from signalwire.skills.spider.skill import SpiderSkill + schema = SpiderSkill.get_parameter_schema() # SkillBase adds swaig_fields and tool_name for multi-instance assert "swaig_fields" in schema @@ -167,15 +196,21 @@ def test_includes_base_schema_keys(self) -> None: def test_delay_has_correct_defaults(self) -> None: from signalwire.skills.spider.skill import SpiderSkill + schema = SpiderSkill.get_parameter_schema() assert schema["delay"]["default"] == 0.1 assert schema["delay"]["type"] == "number" def test_extract_type_enum(self) -> None: from signalwire.skills.spider.skill import SpiderSkill + schema = SpiderSkill.get_parameter_schema() assert set(schema["extract_type"]["enum"]) == { - "fast_text", "clean_text", "full_text", "html", "custom" + "fast_text", + "clean_text", + "full_text", + "html", + "custom", } @@ -183,6 +218,7 @@ def test_extract_type_enum(self) -> None: # __init__ # =================================================================== + class TestSpiderSkillInit: """Verify that __init__ correctly stores parameters and sets up state.""" @@ -242,8 +278,8 @@ def test_agent_stored(self, default_skill: "SpiderSkill", mock_agent: Mock) -> N # get_instance_key # =================================================================== -class TestGetInstanceKey: +class TestGetInstanceKey: def test_default_instance_key(self, default_skill: "SpiderSkill") -> None: # No tool_name in params; falls back to SKILL_NAME key = default_skill.get_instance_key() @@ -258,36 +294,50 @@ def test_custom_instance_key(self, custom_skill: "SpiderSkill") -> None: # setup # =================================================================== -class TestSetup: - def test_valid_configuration_returns_true(self, default_skill: "SpiderSkill") -> None: +class TestSetup: + def test_valid_configuration_returns_true( + self, default_skill: "SpiderSkill" + ) -> None: assert default_skill.setup() is True def test_negative_delay_returns_false(self, default_skill: "SpiderSkill") -> None: default_skill.delay = -1 assert default_skill.setup() is False - def test_concurrent_requests_too_low_returns_false(self, default_skill: "SpiderSkill") -> None: + def test_concurrent_requests_too_low_returns_false( + self, default_skill: "SpiderSkill" + ) -> None: default_skill.concurrent_requests = 0 assert default_skill.setup() is False - def test_concurrent_requests_too_high_returns_false(self, default_skill: "SpiderSkill") -> None: + def test_concurrent_requests_too_high_returns_false( + self, default_skill: "SpiderSkill" + ) -> None: default_skill.concurrent_requests = 21 assert default_skill.setup() is False - def test_max_pages_too_low_returns_false(self, default_skill: "SpiderSkill") -> None: + def test_max_pages_too_low_returns_false( + self, default_skill: "SpiderSkill" + ) -> None: default_skill.max_pages = 0 assert default_skill.setup() is False - def test_negative_max_depth_returns_false(self, default_skill: "SpiderSkill") -> None: + def test_negative_max_depth_returns_false( + self, default_skill: "SpiderSkill" + ) -> None: default_skill.max_depth = -1 assert default_skill.setup() is False - def test_boundary_concurrent_requests_low(self, default_skill: "SpiderSkill") -> None: + def test_boundary_concurrent_requests_low( + self, default_skill: "SpiderSkill" + ) -> None: default_skill.concurrent_requests = 1 assert default_skill.setup() is True - def test_boundary_concurrent_requests_high(self, default_skill: "SpiderSkill") -> None: + def test_boundary_concurrent_requests_high( + self, default_skill: "SpiderSkill" + ) -> None: default_skill.concurrent_requests = 20 assert default_skill.setup() is True @@ -310,24 +360,28 @@ def test_setup_logs_info_on_success(self, default_skill: "SpiderSkill") -> None: # register_tools # =================================================================== -class TestRegisterTools: +class TestRegisterTools: def test_registers_three_tools(self, default_skill: "SpiderSkill") -> None: default_skill.register_tools() assert default_skill.agent.define_tool.call_count == 3 def test_tool_names_without_prefix(self, default_skill: "SpiderSkill") -> None: default_skill.register_tools() - names = [call.kwargs.get("name") or call[1].get("name") - for call in default_skill.agent.define_tool.call_args_list] + names = [ + call.kwargs.get("name") or call[1].get("name") + for call in default_skill.agent.define_tool.call_args_list + ] assert "scrape_url" in names assert "crawl_site" in names assert "extract_structured_data" in names def test_tool_names_with_prefix(self, custom_skill: "SpiderSkill") -> None: custom_skill.register_tools() - names = [call.kwargs.get("name") or call[1].get("name") - for call in custom_skill.agent.define_tool.call_args_list] + names = [ + call.kwargs.get("name") or call[1].get("name") + for call in custom_skill.agent.define_tool.call_args_list + ] assert "my_spider_scrape_url" in names assert "my_spider_crawl_site" in names assert "my_spider_extract_structured_data" in names @@ -343,8 +397,8 @@ def test_handlers_are_callable(self, default_skill: "SpiderSkill") -> None: # _fetch_url # =================================================================== -class TestFetchUrl: +class TestFetchUrl: def test_returns_cached_response(self, default_skill: "SpiderSkill") -> None: cached = _make_mock_response() assert default_skill._cache is not None @@ -352,7 +406,9 @@ def test_returns_cached_response(self, default_skill: "SpiderSkill") -> None: result = default_skill._fetch_url("https://cached.com") assert result is cached - def test_successful_fetch_stores_in_cache(self, default_skill: "SpiderSkill") -> None: + def test_successful_fetch_stores_in_cache( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response() default_skill.session.get = Mock(return_value=resp) # type: ignore[method-assign] # mock result = default_skill._fetch_url("https://example.com") @@ -360,7 +416,9 @@ def test_successful_fetch_stores_in_cache(self, default_skill: "SpiderSkill") -> assert default_skill._cache is not None assert "https://example.com" in default_skill._cache - def test_successful_fetch_no_cache_when_disabled(self, custom_skill: "SpiderSkill") -> None: + def test_successful_fetch_no_cache_when_disabled( + self, custom_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response() custom_skill.session.get = Mock(return_value=resp) # type: ignore[method-assign] # mock result = custom_skill._fetch_url("https://example.com") @@ -369,60 +427,74 @@ def test_successful_fetch_no_cache_when_disabled(self, custom_skill: "SpiderSkil def test_timeout_returns_none(self, default_skill: "SpiderSkill") -> None: import requests as req_mod - default_skill.session.get = Mock(side_effect=req_mod.exceptions.Timeout("timeout")) # type: ignore[method-assign] # mock + + default_skill.session.get = Mock( # type: ignore[method-assign] # mock + side_effect=req_mod.exceptions.Timeout("timeout") + ) result = default_skill._fetch_url("https://slow.com") assert result is None def test_request_exception_returns_none(self, default_skill: "SpiderSkill") -> None: import requests as req_mod + default_skill.session.get = Mock( # type: ignore[method-assign] # mock - side_effect=req_mod.exceptions.ConnectionError("refused")) + side_effect=req_mod.exceptions.ConnectionError("refused") + ) result = default_skill._fetch_url("https://down.com") assert result is None def test_http_error_returns_none(self, default_skill: "SpiderSkill") -> None: import requests as req_mod + resp = _make_mock_response() resp.raise_for_status.side_effect = req_mod.exceptions.HTTPError("404") default_skill.session.get = Mock(return_value=resp) # type: ignore[method-assign] # mock result = default_skill._fetch_url("https://missing.com") assert result is None - def test_timeout_kwarg_passed_to_session(self, default_skill: "SpiderSkill") -> None: + def test_timeout_kwarg_passed_to_session( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response() default_skill.session.get = Mock(return_value=resp) # type: ignore[method-assign] # mock default_skill._fetch_url("https://example.com") default_skill.session.get.assert_called_once_with( - "https://example.com", timeout=default_skill.timeout) + "https://example.com", timeout=default_skill.timeout + ) # =================================================================== # _fast_text_extract # =================================================================== -class TestFastTextExtract: +class TestFastTextExtract: def test_extracts_text_from_html(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( - content=b"

Hello world

") + content=b"

Hello world

" + ) text = default_skill._fast_text_extract(resp) assert "Hello world" in text def test_removes_script_elements(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( - content=b"

Visible

") + content=b"

Visible

" + ) text = default_skill._fast_text_extract(resp) assert "var x=1" not in text assert "Visible" in text def test_removes_style_elements(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( - content=b"

Visible

") + content=b"

Visible

" + ) text = default_skill._fast_text_extract(resp) assert "color:red" not in text assert "Visible" in text - def test_removes_nav_header_footer_aside(self, default_skill: "SpiderSkill") -> None: + def test_removes_nav_header_footer_aside( + self, default_skill: "SpiderSkill" + ) -> None: html_content = ( b"" b"" @@ -440,42 +512,58 @@ def test_removes_nav_header_footer_aside(self, default_skill: "SpiderSkill") -> assert "AsideContent" not in text assert "MainContent" in text - def test_clean_text_collapses_whitespace(self, default_skill: "SpiderSkill") -> None: + def test_clean_text_collapses_whitespace( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response( - content=b"

Hello \n\n world

") + content=b"

Hello \n\n world

" + ) text = default_skill._fast_text_extract(resp) # clean_text is True by default, so multiple whitespace should collapse assert "Hello world" in text - def test_no_clean_text_preserves_whitespace(self, custom_skill: "SpiderSkill") -> None: + def test_no_clean_text_preserves_whitespace( + self, custom_skill: "SpiderSkill" + ) -> None: # custom_skill has clean_text=False resp = _make_mock_response( - content=b"

Hello world

") + content=b"

Hello world

" + ) text = custom_skill._fast_text_extract(resp) # Whitespace may not be fully collapsed assert "Hello" in text assert "world" in text - def test_truncation_when_text_exceeds_max_length(self, default_skill: "SpiderSkill") -> None: + def test_truncation_when_text_exceeds_max_length( + self, default_skill: "SpiderSkill" + ) -> None: # default max_text_length is 3000 long_text = "A" * 5000 resp = _make_mock_response( - content=f"

{long_text}

".encode()) + content=f"

{long_text}

".encode() + ) text = default_skill._fast_text_extract(resp) assert "[...CONTENT TRUNCATED...]" in text # Text should be around max_text_length plus the truncation marker assert len(text) < 5000 + 100 - def test_no_truncation_when_within_limit(self, default_skill: "SpiderSkill") -> None: + def test_no_truncation_when_within_limit( + self, default_skill: "SpiderSkill" + ) -> None: short_text = "A" * 100 resp = _make_mock_response( - content=f"

{short_text}

".encode()) + content=f"

{short_text}

".encode() + ) text = default_skill._fast_text_extract(resp) assert "[...CONTENT TRUNCATED...]" not in text - def test_returns_empty_string_on_parse_error(self, default_skill: "SpiderSkill") -> None: - with patch("signalwire.skills.spider.skill.html.fromstring", - side_effect=Exception("parse error")): + def test_returns_empty_string_on_parse_error( + self, default_skill: "SpiderSkill" + ) -> None: + with patch( + "signalwire.skills.spider.skill.html.fromstring", + side_effect=Exception("parse error"), + ): resp = _make_mock_response(content=b"not valid html at all") text = default_skill._fast_text_extract(resp) assert text == "" @@ -485,24 +573,29 @@ def test_returns_empty_string_on_parse_error(self, default_skill: "SpiderSkill") # _markdown_extract # =================================================================== -class TestMarkdownExtract: +class TestMarkdownExtract: def test_extracts_title(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( - content=b"My Page

Content

") + content=b"My Page

Content

" + ) text = default_skill._markdown_extract(resp) assert "# My Page" in text def test_extracts_paragraphs(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( - content=b"

Paragraph one

Paragraph two

") + content=b"

Paragraph one

Paragraph two

" + ) text = default_skill._markdown_extract(resp) assert "Paragraph one" in text assert "Paragraph two" in text - def test_extracts_headings_with_correct_level(self, default_skill: "SpiderSkill") -> None: + def test_extracts_headings_with_correct_level( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response( - content=b"

Heading 1

Heading 2

Heading 3

") + content=b"

Heading 1

Heading 2

Heading 3

" + ) text = default_skill._markdown_extract(resp) assert "# Heading 1" in text assert "## Heading 2" in text @@ -510,14 +603,16 @@ def test_extracts_headings_with_correct_level(self, default_skill: "SpiderSkill" def test_extracts_list_items(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( - content=b"
  • Item A
  • Item B
") + content=b"
  • Item A
  • Item B
" + ) text = default_skill._markdown_extract(resp) assert "- Item A" in text assert "- Item B" in text def test_extracts_code_blocks(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( - content=b"
some code
") + content=b"
some code
" + ) text = default_skill._markdown_extract(resp) assert "```" in text assert "some code" in text @@ -539,14 +634,19 @@ def test_removes_unwanted_elements(self, default_skill: "SpiderSkill") -> None: def test_truncation_with_marker(self, default_skill: "SpiderSkill") -> None: long_text = "X" * 5000 resp = _make_mock_response( - content=f"

{long_text}

".encode()) + content=f"

{long_text}

".encode() + ) text = default_skill._markdown_extract(resp) assert "[...TRUNCATED...]" in text - def test_falls_back_to_fast_text_on_import_error(self, default_skill: "SpiderSkill") -> None: + def test_falls_back_to_fast_text_on_import_error( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response( - content=b"

Fallback content

") + content=b"

Fallback content

" + ) import builtins + real_import = builtins.__import__ def fake_import(name: str, *args: Any, **kwargs: Any) -> Any: @@ -556,6 +656,7 @@ def fake_import(name: str, *args: Any, **kwargs: Any) -> Any: # Remove bs4 from sys.modules cache so the import inside the method triggers import sys + saved_bs4 = sys.modules.pop("bs4", None) try: with patch("builtins.__import__", side_effect=fake_import): @@ -566,9 +667,12 @@ def fake_import(name: str, *args: Any, **kwargs: Any) -> Any: if saved_bs4 is not None: sys.modules["bs4"] = saved_bs4 - def test_falls_back_to_fast_text_on_general_error(self, default_skill: "SpiderSkill") -> None: + def test_falls_back_to_fast_text_on_general_error( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response( - content=b"

Some content

") + content=b"

Some content

" + ) with patch("bs4.BeautifulSoup", side_effect=Exception("soup error")): text = default_skill._markdown_extract(resp) # Should fall back to fast_text @@ -579,11 +683,12 @@ def test_falls_back_to_fast_text_on_general_error(self, default_skill: "SpiderSk # _structured_extract # =================================================================== -class TestStructuredExtract: +class TestStructuredExtract: def test_extracts_title(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( - content=b"Test Title") + content=b"Test Title" + ) result = default_skill._structured_extract(resp) assert result["title"] == "Test Title" @@ -593,42 +698,56 @@ def test_result_contains_url_and_status(self, default_skill: "SpiderSkill") -> N assert result["url"] == "https://example.com/page" assert result["status_code"] == 200 - def test_no_selectors_returns_empty_data(self, default_skill: "SpiderSkill") -> None: + def test_no_selectors_returns_empty_data( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response() result = default_skill._structured_extract(resp) assert result["data"] == {} def test_xpath_selector(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( - content=b"

Hello

") + content=b"

Hello

" + ) result = default_skill._structured_extract(resp, selectors={"paragraph": "//p"}) assert "paragraph" in result["data"] assert "Hello" in result["data"]["paragraph"] - def test_xpath_selector_multiple_results(self, default_skill: "SpiderSkill") -> None: + def test_xpath_selector_multiple_results( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response( - content=b"

One

Two

Three

") + content=b"

One

Two

Three

" + ) result = default_skill._structured_extract(resp, selectors={"items": "//p"}) assert isinstance(result["data"]["items"], list) assert len(result["data"]["items"]) == 3 def test_xpath_selector_single_result(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( - content=b"

Only One

") + content=b"

Only One

" + ) result = default_skill._structured_extract(resp, selectors={"heading": "//h1"}) # Single result should be a string, not a list assert isinstance(result["data"]["heading"], str) assert result["data"]["heading"] == "Only One" - def test_invalid_xpath_returns_none_for_field(self, default_skill: "SpiderSkill") -> None: + def test_invalid_xpath_returns_none_for_field( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response(content=b"") result = default_skill._structured_extract( - resp, selectors={"bad": "///invalid[["}) + resp, selectors={"bad": "///invalid[["} + ) assert result["data"]["bad"] is None - def test_general_parse_error_returns_error_dict(self, default_skill: "SpiderSkill") -> None: - with patch("signalwire.skills.spider.skill.html.fromstring", - side_effect=Exception("parse failed")): + def test_general_parse_error_returns_error_dict( + self, default_skill: "SpiderSkill" + ) -> None: + with patch( + "signalwire.skills.spider.skill.html.fromstring", + side_effect=Exception("parse failed"), + ): resp = _make_mock_response() result = default_skill._structured_extract(resp) assert "error" in result @@ -643,14 +762,18 @@ def test_no_title_returns_empty_string(self, default_skill: "SpiderSkill") -> No # _scrape_url_handler # =================================================================== -class TestScrapeUrlHandler: - def test_empty_url_returns_error_message(self, default_skill: "SpiderSkill") -> None: +class TestScrapeUrlHandler: + def test_empty_url_returns_error_message( + self, default_skill: "SpiderSkill" + ) -> None: result = default_skill._scrape_url_handler({"url": ""}, {}) assert isinstance(result, FunctionResult) assert "provide a URL" in result.response - def test_missing_url_returns_error_message(self, default_skill: "SpiderSkill") -> None: + def test_missing_url_returns_error_message( + self, default_skill: "SpiderSkill" + ) -> None: result = default_skill._scrape_url_handler({}, {}) assert "provide a URL" in result.response @@ -665,80 +788,123 @@ def test_invalid_url_no_netloc(self, default_skill: "SpiderSkill") -> None: def test_fetch_failure_returns_error(self, default_skill: "SpiderSkill") -> None: with patch.object(default_skill, "_fetch_url", return_value=None): result = default_skill._scrape_url_handler( - {"url": "https://example.com"}, {}) + {"url": "https://example.com"}, {} + ) assert "Failed to fetch" in result.response - def test_successful_fast_text_extraction(self, default_skill: "SpiderSkill") -> None: + def test_successful_fast_text_extraction( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response() - with patch.object(default_skill, "_fetch_url", return_value=resp): - with patch.object(default_skill, "_fast_text_extract", - return_value="Extracted content here"): - result = default_skill._scrape_url_handler( - {"url": "https://example.com"}, {}) - assert "Extracted content here" in result.response - assert "Content from" in result.response + with ( + patch.object(default_skill, "_fetch_url", return_value=resp), + patch.object( + default_skill, + "_fast_text_extract", + return_value="Extracted content here", + ), + ): + result = default_skill._scrape_url_handler( + {"url": "https://example.com"}, {} + ) + assert "Extracted content here" in result.response + assert "Content from" in result.response def test_successful_markdown_extraction(self, default_skill: "SpiderSkill") -> None: default_skill.extract_type = "markdown" resp = _make_mock_response() - with patch.object(default_skill, "_fetch_url", return_value=resp): - with patch.object(default_skill, "_markdown_extract", - return_value="# Markdown content"): - result = default_skill._scrape_url_handler( - {"url": "https://example.com"}, {}) - assert "# Markdown content" in result.response + with ( + patch.object(default_skill, "_fetch_url", return_value=resp), + patch.object( + default_skill, "_markdown_extract", return_value="# Markdown content" + ), + ): + result = default_skill._scrape_url_handler( + {"url": "https://example.com"}, {} + ) + assert "# Markdown content" in result.response def test_structured_extraction(self, default_skill: "SpiderSkill") -> None: default_skill.extract_type = "structured" resp = _make_mock_response() - structured_data = {"url": "https://example.com", "title": "Test", - "status_code": 200, "data": {"field": "value"}} - with patch.object(default_skill, "_fetch_url", return_value=resp): - with patch.object(default_skill, "_structured_extract", - return_value=structured_data): - result = default_skill._scrape_url_handler( - {"url": "https://example.com"}, {}) - assert "Extracted structured data" in result.response - - def test_empty_content_returns_no_content_message(self, default_skill: "SpiderSkill") -> None: + structured_data = { + "url": "https://example.com", + "title": "Test", + "status_code": 200, + "data": {"field": "value"}, + } + with ( + patch.object(default_skill, "_fetch_url", return_value=resp), + patch.object( + default_skill, "_structured_extract", return_value=structured_data + ), + ): + result = default_skill._scrape_url_handler( + {"url": "https://example.com"}, {} + ) + assert "Extracted structured data" in result.response + + def test_empty_content_returns_no_content_message( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response() - with patch.object(default_skill, "_fetch_url", return_value=resp): - with patch.object(default_skill, "_fast_text_extract", - return_value=""): - result = default_skill._scrape_url_handler( - {"url": "https://example.com"}, {}) - assert "No content extracted" in result.response - - def test_exception_during_extraction_returns_error(self, default_skill: "SpiderSkill") -> None: + with ( + patch.object(default_skill, "_fetch_url", return_value=resp), + patch.object(default_skill, "_fast_text_extract", return_value=""), + ): + result = default_skill._scrape_url_handler( + {"url": "https://example.com"}, {} + ) + assert "No content extracted" in result.response + + def test_exception_during_extraction_returns_error( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response() - with patch.object(default_skill, "_fetch_url", return_value=resp): - with patch.object(default_skill, "_fast_text_extract", - side_effect=RuntimeError("boom")): - result = default_skill._scrape_url_handler( - {"url": "https://example.com"}, {}) - assert "Error processing" in result.response - - def test_uses_configured_extract_type_not_from_args(self, default_skill: "SpiderSkill") -> None: + with ( + patch.object(default_skill, "_fetch_url", return_value=resp), + patch.object( + default_skill, "_fast_text_extract", side_effect=RuntimeError("boom") + ), + ): + result = default_skill._scrape_url_handler( + {"url": "https://example.com"}, {} + ) + assert "Error processing" in result.response + + def test_uses_configured_extract_type_not_from_args( + self, default_skill: "SpiderSkill" + ) -> None: """Verify that extract_type comes from self.extract_type, not args.""" resp = _make_mock_response() - with patch.object(default_skill, "_fetch_url", return_value=resp): - with patch.object(default_skill, "_fast_text_extract", - return_value="content") as mock_fast: - # Even if args had extract_type, it should be ignored - default_skill._scrape_url_handler( - {"url": "https://example.com", "extract_type": "markdown"}, {}) - mock_fast.assert_called_once() - - def test_response_includes_character_count(self, default_skill: "SpiderSkill") -> None: + with ( + patch.object(default_skill, "_fetch_url", return_value=resp), + patch.object( + default_skill, "_fast_text_extract", return_value="content" + ) as mock_fast, + ): + # Even if args had extract_type, it should be ignored + default_skill._scrape_url_handler( + {"url": "https://example.com", "extract_type": "markdown"}, {} + ) + mock_fast.assert_called_once() + + def test_response_includes_character_count( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response() - with patch.object(default_skill, "_fetch_url", return_value=resp): - with patch.object(default_skill, "_fast_text_extract", - return_value="12345"): - result = default_skill._scrape_url_handler( - {"url": "https://example.com"}, {}) - assert "5 characters" in result.response - - def test_whitespace_url_treated_as_empty(self, default_skill: "SpiderSkill") -> None: + with ( + patch.object(default_skill, "_fetch_url", return_value=resp), + patch.object(default_skill, "_fast_text_extract", return_value="12345"), + ): + result = default_skill._scrape_url_handler( + {"url": "https://example.com"}, {} + ) + assert "5 characters" in result.response + + def test_whitespace_url_treated_as_empty( + self, default_skill: "SpiderSkill" + ) -> None: result = default_skill._scrape_url_handler({"url": " "}, {}) assert "provide a URL" in result.response @@ -747,53 +913,59 @@ def test_whitespace_url_treated_as_empty(self, default_skill: "SpiderSkill") -> # _crawl_site_handler # =================================================================== -class TestCrawlSiteHandler: +class TestCrawlSiteHandler: def test_empty_start_url_returns_error(self, default_skill: "SpiderSkill") -> None: result = default_skill._crawl_site_handler({"start_url": ""}, {}) assert "provide a starting URL" in result.response - def test_missing_start_url_returns_error(self, default_skill: "SpiderSkill") -> None: + def test_missing_start_url_returns_error( + self, default_skill: "SpiderSkill" + ) -> None: result = default_skill._crawl_site_handler({}, {}) assert "provide a starting URL" in result.response def test_single_page_crawl(self, default_skill: "SpiderSkill") -> None: """With max_depth=0 and max_pages=1, should crawl exactly one page.""" resp = _make_mock_response( - content=b"

Page content

") - with patch.object(default_skill, "_fetch_url", return_value=resp): - with patch.object(default_skill, "_fast_text_extract", - return_value="Page content"): - result = default_skill._crawl_site_handler( - {"start_url": "https://example.com"}, {}) - assert "Crawled 1 pages" in result.response + content=b"

Page content

" + ) + with ( + patch.object(default_skill, "_fetch_url", return_value=resp), + patch.object( + default_skill, "_fast_text_extract", return_value="Page content" + ), + ): + result = default_skill._crawl_site_handler( + {"start_url": "https://example.com"}, {} + ) + assert "Crawled 1 pages" in result.response def test_no_pages_crawled_returns_error(self, default_skill: "SpiderSkill") -> None: with patch.object(default_skill, "_fetch_url", return_value=None): result = default_skill._crawl_site_handler( - {"start_url": "https://example.com"}, {}) + {"start_url": "https://example.com"}, {} + ) assert "No pages could be crawled" in result.response - def test_multi_page_crawl_respects_max_pages(self, default_skill: "SpiderSkill") -> None: + def test_multi_page_crawl_respects_max_pages( + self, default_skill: "SpiderSkill" + ) -> None: default_skill.max_pages = 2 default_skill.max_depth = 1 default_skill.delay = 0 # Avoid sleep in tests page1_content = ( - b"" - b"Link" - b"

Page 1

" - b"" + b"Link

Page 1

" ) page2_content = ( - b"" - b"Link" - b"

Page 2

" - b"" + b"Link

Page 2

" ) resp1 = _make_mock_response(content=page1_content, url="https://example.com") - resp2 = _make_mock_response(content=page2_content, url="https://example.com/page2") + resp2 = _make_mock_response( + content=page2_content, url="https://example.com/page2" + ) call_count = [0] @@ -807,10 +979,13 @@ def mock_fetch(url: str) -> Mock | None: with patch.object(default_skill, "_fetch_url", side_effect=mock_fetch): result = default_skill._crawl_site_handler( - {"start_url": "https://example.com"}, {}) + {"start_url": "https://example.com"}, {} + ) assert "Crawled 2 pages" in result.response - def test_crawl_skips_already_visited_urls(self, default_skill: "SpiderSkill") -> None: + def test_crawl_skips_already_visited_urls( + self, default_skill: "SpiderSkill" + ) -> None: default_skill.max_pages = 10 default_skill.max_depth = 1 default_skill.delay = 0 @@ -832,7 +1007,8 @@ def mock_fetch(url: str) -> Mock | None: with patch.object(default_skill, "_fetch_url", side_effect=mock_fetch): result = default_skill._crawl_site_handler( - {"start_url": "https://example.com"}, {}) + {"start_url": "https://example.com"}, {} + ) # Should only fetch the page once; the self-links should be recognized as visited assert len(fetch_calls) == 1 assert "Crawled 1 pages" in result.response @@ -843,10 +1019,7 @@ def test_crawl_respects_max_depth(self, default_skill: "SpiderSkill") -> None: default_skill.delay = 0 page_content = ( - b"" - b"Link" - b"

Content

" - b"" + b"Link

Content

" ) resp = _make_mock_response(content=page_content, url="https://example.com") @@ -857,8 +1030,7 @@ def mock_fetch(url: str) -> Mock | None: return resp with patch.object(default_skill, "_fetch_url", side_effect=mock_fetch): - result = default_skill._crawl_site_handler( - {"start_url": "https://example.com"}, {}) + default_skill._crawl_site_handler({"start_url": "https://example.com"}, {}) # With max_depth=0, should not follow links assert len(fetch_calls) == 1 @@ -877,7 +1049,8 @@ def test_crawl_follows_same_domain_only(self, default_skill: "SpiderSkill") -> N resp = _make_mock_response(content=page_content, url="https://example.com") resp2 = _make_mock_response( content=b"

Internal

", - url="https://example.com/internal") + url="https://example.com/internal", + ) fetch_calls = [] @@ -888,8 +1061,7 @@ def mock_fetch(url: str) -> Mock | None: return resp with patch.object(default_skill, "_fetch_url", side_effect=mock_fetch): - result = default_skill._crawl_site_handler( - {"start_url": "https://example.com"}, {}) + default_skill._crawl_site_handler({"start_url": "https://example.com"}, {}) # Should not have fetched external domain assert not any("other.com" in u for u in fetch_calls) @@ -910,7 +1082,8 @@ def test_crawl_with_follow_patterns(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response(content=page_content, url="https://example.com") blog_resp = _make_mock_response( content=b"

Blog

", - url="https://example.com/blog/post1") + url="https://example.com/blog/post1", + ) fetch_calls = [] @@ -921,32 +1094,36 @@ def mock_fetch(url: str) -> Mock | None: return resp with patch.object(default_skill, "_fetch_url", side_effect=mock_fetch): - result = default_skill._crawl_site_handler( - {"start_url": "https://example.com"}, {}) + default_skill._crawl_site_handler({"start_url": "https://example.com"}, {}) # Should follow the blog link but not the about link assert any("blog" in u for u in fetch_calls) assert not any("about" in u for u in fetch_calls) - def test_crawl_summary_contains_total_characters(self, default_skill: "SpiderSkill") -> None: + def test_crawl_summary_contains_total_characters( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response() - with patch.object(default_skill, "_fetch_url", return_value=resp): - with patch.object(default_skill, "_fast_text_extract", - return_value="Hello World"): - result = default_skill._crawl_site_handler( - {"start_url": "https://example.com"}, {}) - assert "Total content:" in result.response - assert "characters" in result.response - - def test_crawl_handles_fetch_failure_for_individual_pages(self, default_skill: "SpiderSkill") -> None: + with ( + patch.object(default_skill, "_fetch_url", return_value=resp), + patch.object( + default_skill, "_fast_text_extract", return_value="Hello World" + ), + ): + result = default_skill._crawl_site_handler( + {"start_url": "https://example.com"}, {} + ) + assert "Total content:" in result.response + assert "characters" in result.response + + def test_crawl_handles_fetch_failure_for_individual_pages( + self, default_skill: "SpiderSkill" + ) -> None: default_skill.max_pages = 5 default_skill.max_depth = 1 default_skill.delay = 0 page_content = ( - b"" - b"Link" - b"

Content

" - b"" + b"Link

Content

" ) resp = _make_mock_response(content=page_content, url="https://example.com") @@ -960,7 +1137,8 @@ def mock_fetch(url: str) -> Mock | None: with patch.object(default_skill, "_fetch_url", side_effect=mock_fetch): result = default_skill._crawl_site_handler( - {"start_url": "https://example.com"}, {}) + {"start_url": "https://example.com"}, {} + ) assert "Crawled 1 pages" in result.response def test_crawl_delays_between_requests(self, default_skill: "SpiderSkill") -> None: @@ -969,15 +1147,13 @@ def test_crawl_delays_between_requests(self, default_skill: "SpiderSkill") -> No default_skill.delay = 0.5 page1_content = ( - b"" - b"Link" - b"

Page 1

" - b"" + b"Link

Page 1

" ) resp1 = _make_mock_response(content=page1_content, url="https://example.com") resp2 = _make_mock_response( content=b"

Page 2

", - url="https://example.com/page2") + url="https://example.com/page2", + ) call_count = [0] @@ -987,30 +1163,41 @@ def mock_fetch(url: str) -> Mock | None: return resp1 return resp2 - with patch.object(default_skill, "_fetch_url", side_effect=mock_fetch): - with patch("time.sleep") as mock_sleep: - result = default_skill._crawl_site_handler( - {"start_url": "https://example.com"}, {}) - mock_sleep.assert_called_with(0.5) - - def test_content_summary_truncated_at_500(self, default_skill: "SpiderSkill") -> None: + with ( + patch.object(default_skill, "_fetch_url", side_effect=mock_fetch), + patch("time.sleep") as mock_sleep, + ): + result = default_skill._crawl_site_handler( + {"start_url": "https://example.com"}, {} + ) + mock_sleep.assert_called_with(0.5) + # The handler must still return a result, not just pace itself. + assert result is not None + + def test_content_summary_truncated_at_500( + self, default_skill: "SpiderSkill" + ) -> None: long_content = "A" * 1000 resp = _make_mock_response() - with patch.object(default_skill, "_fetch_url", return_value=resp): - with patch.object(default_skill, "_fast_text_extract", - return_value=long_content): - result = default_skill._crawl_site_handler( - {"start_url": "https://example.com"}, {}) - # The summary field should be truncated at 500 chars - assert "..." in result.response + with ( + patch.object(default_skill, "_fetch_url", return_value=resp), + patch.object( + default_skill, "_fast_text_extract", return_value=long_content + ), + ): + result = default_skill._crawl_site_handler( + {"start_url": "https://example.com"}, {} + ) + # The summary field should be truncated at 500 chars + assert "..." in result.response # =================================================================== # _extract_structured_handler # =================================================================== -class TestExtractStructuredHandler: +class TestExtractStructuredHandler: def test_empty_url_returns_error(self, default_skill: "SpiderSkill") -> None: result = default_skill._extract_structured_handler({"url": ""}, {}) assert "provide a URL" in result.response @@ -1019,16 +1206,20 @@ def test_missing_url_returns_error(self, default_skill: "SpiderSkill") -> None: result = default_skill._extract_structured_handler({}, {}) assert "provide a URL" in result.response - def test_no_selectors_configured_returns_error(self, default_skill: "SpiderSkill") -> None: + def test_no_selectors_configured_returns_error( + self, default_skill: "SpiderSkill" + ) -> None: result = default_skill._extract_structured_handler( - {"url": "https://example.com"}, {}) + {"url": "https://example.com"}, {} + ) assert "No selectors configured" in result.response def test_fetch_failure_returns_error(self, custom_skill: "SpiderSkill") -> None: # custom_skill has selectors configured with patch.object(custom_skill, "_fetch_url", return_value=None): result = custom_skill._extract_structured_handler( - {"url": "https://example.com"}, {}) + {"url": "https://example.com"}, {} + ) assert "Failed to fetch" in result.response def test_successful_extraction(self, custom_skill: "SpiderSkill") -> None: @@ -1036,62 +1227,82 @@ def test_successful_extraction(self, custom_skill: "SpiderSkill") -> None: "url": "https://example.com", "title": "Test Page", "status_code": 200, - "data": {"title": "Extracted Title"} + "data": {"title": "Extracted Title"}, } resp = _make_mock_response() - with patch.object(custom_skill, "_fetch_url", return_value=resp): - with patch.object(custom_skill, "_structured_extract", - return_value=structured_result): - result = custom_skill._extract_structured_handler( - {"url": "https://example.com"}, {}) - assert "Extracted data from" in result.response - assert "Test Page" in result.response - assert "title: Extracted Title" in result.response + with ( + patch.object(custom_skill, "_fetch_url", return_value=resp), + patch.object( + custom_skill, "_structured_extract", return_value=structured_result + ), + ): + result = custom_skill._extract_structured_handler( + {"url": "https://example.com"}, {} + ) + assert "Extracted data from" in result.response + assert "Test Page" in result.response + assert "title: Extracted Title" in result.response def test_extraction_error_in_result(self, custom_skill: "SpiderSkill") -> None: resp = _make_mock_response() - with patch.object(custom_skill, "_fetch_url", return_value=resp): - with patch.object(custom_skill, "_structured_extract", - return_value={"error": "Something went wrong"}): - result = custom_skill._extract_structured_handler( - {"url": "https://example.com"}, {}) - assert "Error extracting data" in result.response - - def test_empty_data_says_no_data_extracted(self, custom_skill: "SpiderSkill") -> None: + with ( + patch.object(custom_skill, "_fetch_url", return_value=resp), + patch.object( + custom_skill, + "_structured_extract", + return_value={"error": "Something went wrong"}, + ), + ): + result = custom_skill._extract_structured_handler( + {"url": "https://example.com"}, {} + ) + assert "Error extracting data" in result.response + + def test_empty_data_says_no_data_extracted( + self, custom_skill: "SpiderSkill" + ) -> None: structured_result = { "url": "https://example.com", "title": "Test Page", "status_code": 200, - "data": {} + "data": {}, } resp = _make_mock_response() - with patch.object(custom_skill, "_fetch_url", return_value=resp): - with patch.object(custom_skill, "_structured_extract", - return_value=structured_result): - result = custom_skill._extract_structured_handler( - {"url": "https://example.com"}, {}) - assert "No data extracted" in result.response + with ( + patch.object(custom_skill, "_fetch_url", return_value=resp), + patch.object( + custom_skill, "_structured_extract", return_value=structured_result + ), + ): + result = custom_skill._extract_structured_handler( + {"url": "https://example.com"}, {} + ) + assert "No data extracted" in result.response def test_uses_selectors_from_params(self, custom_skill: "SpiderSkill") -> None: resp = _make_mock_response() - with patch.object(custom_skill, "_fetch_url", return_value=resp): - with patch.object(custom_skill, "_structured_extract", - return_value={"url": "", "title": "", "status_code": 200, - "data": {}}) as mock_extract: - custom_skill._extract_structured_handler( - {"url": "https://example.com"}, {}) - # Verify selectors from params are passed - call_args = mock_extract.call_args - assert call_args[1].get("selectors") == {"title": "//title/text()"} or \ - call_args[0][1] == {"title": "//title/text()"} + with ( + patch.object(custom_skill, "_fetch_url", return_value=resp), + patch.object( + custom_skill, + "_structured_extract", + return_value={"url": "", "title": "", "status_code": 200, "data": {}}, + ) as mock_extract, + ): + custom_skill._extract_structured_handler({"url": "https://example.com"}, {}) + # Verify selectors from params are passed + call_args = mock_extract.call_args + assert call_args[1].get("selectors") == { + "title": "//title/text()" + } or call_args[0][1] == {"title": "//title/text()"} # =================================================================== # get_hints # =================================================================== -class TestGetHints: +class TestGetHints: def test_returns_list(self, default_skill: "SpiderSkill") -> None: hints = default_skill.get_hints() assert isinstance(hints, list) @@ -1113,8 +1324,8 @@ def test_hints_are_strings(self, default_skill: "SpiderSkill") -> None: # cleanup # =================================================================== -class TestCleanup: +class TestCleanup: def test_closes_session(self, default_skill: "SpiderSkill") -> None: default_skill.cleanup() default_skill.session.close.assert_called_once() # type: ignore[attr-defined] # mock attr @@ -1138,7 +1349,9 @@ def test_cleanup_with_none_cache(self, custom_skill: "SpiderSkill") -> None: # Cache stays None — no surprise re-init. assert custom_skill._cache is None - def test_cleanup_without_session_attribute(self, default_skill: "SpiderSkill") -> None: + def test_cleanup_without_session_attribute( + self, default_skill: "SpiderSkill" + ) -> None: """If `session` was never created, the hasattr guard must skip the close path. We verify cache.clear() still ran (the second guard is independent).""" @@ -1152,7 +1365,9 @@ def test_cleanup_without_session_attribute(self, default_skill: "SpiderSkill") - # Session still missing — no re-creation. assert not hasattr(default_skill, "session") - def test_cleanup_without_cache_attribute(self, default_skill: "SpiderSkill") -> None: + def test_cleanup_without_cache_attribute( + self, default_skill: "SpiderSkill" + ) -> None: """If `cache` was never created, the hasattr guard must skip the clear path while still closing the session.""" del default_skill._cache @@ -1172,19 +1387,23 @@ def test_cleanup_logs_info(self, default_skill: "SpiderSkill") -> None: # Edge cases and integration-style scenarios # =================================================================== -class TestEdgeCases: +class TestEdgeCases: def test_url_with_whitespace_stripped(self, default_skill: "SpiderSkill") -> None: """URLs with leading/trailing whitespace should be stripped.""" resp = _make_mock_response() - with patch.object(default_skill, "_fetch_url", return_value=resp): - with patch.object(default_skill, "_fast_text_extract", - return_value="content"): - result = default_skill._scrape_url_handler( - {"url": " https://example.com "}, {}) - assert "content" in result.response - - def test_cache_prevents_duplicate_fetches(self, default_skill: "SpiderSkill") -> None: + with ( + patch.object(default_skill, "_fetch_url", return_value=resp), + patch.object(default_skill, "_fast_text_extract", return_value="content"), + ): + result = default_skill._scrape_url_handler( + {"url": " https://example.com "}, {} + ) + assert "content" in result.response + + def test_cache_prevents_duplicate_fetches( + self, default_skill: "SpiderSkill" + ) -> None: resp = _make_mock_response() default_skill.session.get = Mock(return_value=resp) # type: ignore[method-assign] # mock @@ -1196,7 +1415,9 @@ def test_cache_prevents_duplicate_fetches(self, default_skill: "SpiderSkill") -> default_skill.session.get.assert_called_once() assert result1 is result2 - def test_scrape_handler_url_with_only_scheme(self, default_skill: "SpiderSkill") -> None: + def test_scrape_handler_url_with_only_scheme( + self, default_skill: "SpiderSkill" + ) -> None: result = default_skill._scrape_url_handler({"url": "ftp://"}, {}) assert "Invalid URL" in result.response @@ -1208,6 +1429,7 @@ def test_init_with_empty_params(self, mock_agent: Mock) -> None: MockSession.return_value = mock_session from signalwire.skills.spider.skill import SpiderSkill + skill = SpiderSkill(mock_agent, {}) assert skill.delay == 0.1 assert skill._cache == {} @@ -1220,38 +1442,50 @@ def test_init_with_none_params(self, mock_agent: Mock) -> None: MockSession.return_value = mock_session from signalwire.skills.spider.skill import SpiderSkill + skill = SpiderSkill(mock_agent, None) # type: ignore[arg-type] # None params handled by SkillBase default assert skill.delay == 0.1 - def test_register_tools_no_prefix_when_tool_name_empty(self, mock_agent: Mock) -> None: + def test_register_tools_no_prefix_when_tool_name_empty( + self, mock_agent: Mock + ) -> None: with patch("signalwire.skills.spider.skill.requests.Session") as MockSession: mock_session = Mock() mock_session.headers = {} MockSession.return_value = mock_session from signalwire.skills.spider.skill import SpiderSkill + skill = SpiderSkill(mock_agent, {"tool_name": ""}) skill.register_tools() - names = [call.kwargs.get("name") or call[1].get("name") - for call in mock_agent.define_tool.call_args_list] + names = [ + call.kwargs.get("name") or call[1].get("name") + for call in mock_agent.define_tool.call_args_list + ] # Empty tool_name should not add a prefix assert "scrape_url" in names - def test_fast_text_truncation_preserves_start_and_end(self, default_skill: "SpiderSkill") -> None: + def test_fast_text_truncation_preserves_start_and_end( + self, default_skill: "SpiderSkill" + ) -> None: """Verify the smart truncation keeps 2/3 from start and 1/3 from end.""" default_skill.max_text_length = 300 body = "S" * 200 + "M" * 100 + "E" * 200 resp = _make_mock_response( - content=f"

{body}

".encode()) + content=f"

{body}

".encode() + ) text = default_skill._fast_text_extract(resp) assert text.startswith("S") assert text.endswith("E") assert "[...CONTENT TRUNCATED...]" in text - def test_structured_extract_css_selector(self, default_skill: "SpiderSkill") -> None: + def test_structured_extract_css_selector( + self, default_skill: "SpiderSkill" + ) -> None: """CSS selectors (not starting with /) should be handled via CSSSelector.""" resp = _make_mock_response( - content=b"

CSS content

") + content=b"

CSS content

" + ) mock_element = Mock() mock_element.text_content.return_value = "CSS content" @@ -1263,6 +1497,7 @@ def test_structured_extract_css_selector(self, default_skill: "SpiderSkill") -> # The import is `from lxml.cssselect import CSSSelector` inside the method. # Create a fake module and inject it into sys.modules. import sys + fake_cssselect = MagicMock() fake_cssselect.CSSSelector = mock_css_cls @@ -1270,7 +1505,8 @@ def test_structured_extract_css_selector(self, default_skill: "SpiderSkill") -> sys.modules["lxml.cssselect"] = fake_cssselect try: result = default_skill._structured_extract( - resp, selectors={"para": "div.content p"}) + resp, selectors={"para": "div.content p"} + ) assert "para" in result["data"] assert result["data"]["para"] == "CSS content" finally: @@ -1279,21 +1515,27 @@ def test_structured_extract_css_selector(self, default_skill: "SpiderSkill") -> else: sys.modules.pop("lxml.cssselect", None) - def test_crawl_link_extraction_error_handled(self, default_skill: "SpiderSkill") -> None: + def test_crawl_link_extraction_error_handled( + self, default_skill: "SpiderSkill" + ) -> None: """Error during link extraction should not crash the crawl.""" default_skill.max_pages = 5 default_skill.max_depth = 1 default_skill.delay = 0 resp = _make_mock_response() - with patch.object(default_skill, "_fetch_url", return_value=resp): - with patch.object(default_skill, "_fast_text_extract", - return_value="content"): - with patch("signalwire.skills.spider.skill.html.fromstring", - side_effect=Exception("parse error")): - # The crawl handler internally calls html.fromstring for link extraction - # but _fast_text_extract is mocked to succeed - result = default_skill._crawl_site_handler( - {"start_url": "https://example.com"}, {}) - # Should still return results for the page that was crawled - assert "Crawled 1 pages" in result.response + with ( + patch.object(default_skill, "_fetch_url", return_value=resp), + patch.object(default_skill, "_fast_text_extract", return_value="content"), + patch( + "signalwire.skills.spider.skill.html.fromstring", + side_effect=Exception("parse error"), + ), + ): + # The crawl handler internally calls html.fromstring for link extraction + # but _fast_text_extract is mocked to succeed + result = default_skill._crawl_site_handler( + {"start_url": "https://example.com"}, {} + ) + # Should still return results for the page that was crawled + assert "Crawled 1 pages" in result.response diff --git a/tests/unit/skills/test_swml_transfer_skill.py b/tests/unit/skills/test_swml_transfer_skill.py index 80b32ad7..e78b39bf 100644 --- a/tests/unit/skills/test_swml_transfer_skill.py +++ b/tests/unit/skills/test_swml_transfer_skill.py @@ -45,14 +45,14 @@ def _make_skill(params: dict[str, Any] | None = None) -> SWMLTransferSkill: mock_agent = Mock() mock_agent.define_tool = Mock() mock_agent.register_swaig_function = Mock() - skill = SWMLTransferSkill(agent=mock_agent, params=default_params) - return skill + return SWMLTransferSkill(agent=mock_agent, params=default_params) # --------------------------------------------------------------------------- # Class-level attributes # --------------------------------------------------------------------------- + class TestSWMLTransferSkillClassAttributes: """Verify class-level constants and metadata.""" @@ -60,7 +60,10 @@ def test_skill_name(self) -> None: assert SWMLTransferSkill.SKILL_NAME == "swml_transfer" def test_skill_description(self) -> None: - assert SWMLTransferSkill.SKILL_DESCRIPTION == "Transfer calls between agents based on pattern matching" + assert ( + SWMLTransferSkill.SKILL_DESCRIPTION + == "Transfer calls between agents based on pattern matching" + ) def test_skill_version(self) -> None: assert SWMLTransferSkill.SKILL_VERSION == "1.0.0" @@ -79,6 +82,7 @@ def test_supports_multiple_instances(self) -> None: # Initialization # --------------------------------------------------------------------------- + class TestSWMLTransferSkillInit: """Tests for __init__ (inherited from SkillBase).""" @@ -112,6 +116,7 @@ def test_swaig_fields_extracted_from_params(self) -> None: # get_instance_key # --------------------------------------------------------------------------- + class TestGetInstanceKey: """Tests for get_instance_key method.""" @@ -135,6 +140,7 @@ def test_instance_key_before_setup_uses_params(self) -> None: # setup() behaviour # --------------------------------------------------------------------------- + class TestSetup: """Tests for setup() validation and configuration.""" @@ -210,14 +216,15 @@ def test_setup_stores_custom_required_fields(self) -> None: def test_setup_sets_defaults_on_transfer_configs(self) -> None: """Verify setup fills in defaults for optional config fields.""" - transfers = { - "/billing/": {"url": "https://example.com/billing"} - } + transfers = {"/billing/": {"url": "https://example.com/billing"}} skill = _make_skill({"transfers": transfers}) skill.setup() config = skill.transfers["/billing/"] assert config["message"] == "Transferring you now..." - assert config["return_message"] == "The transfer is complete. How else can I help you?" + assert ( + config["return_message"] + == "The transfer is complete. How else can I help you?" + ) assert config["post_process"] is True assert config["final"] is True @@ -245,14 +252,16 @@ def test_setup_fails_transfer_missing_url_and_address(self) -> None: assert skill.setup() is False def test_setup_fails_transfer_has_both_url_and_address(self) -> None: - skill = _make_skill({ - "transfers": { - "/both/": { - "url": "https://example.com/agent", - "address": "+15551234567" + skill = _make_skill( + { + "transfers": { + "/both/": { + "url": "https://example.com/agent", + "address": "+15551234567", + } } } - }) + ) assert skill.setup() is False @@ -260,6 +269,7 @@ def test_setup_fails_transfer_has_both_url_and_address(self) -> None: # register_tools() # --------------------------------------------------------------------------- + class TestRegisterTools: """Tests for register_tools() method.""" @@ -334,11 +344,11 @@ def test_url_transfer_uses_swml_transfer_action(self) -> None: call_args = skill.agent.register_swaig_function.call_args[0][0] expressions = call_args["data_map"]["expressions"] # Find the sales expression (url-based) - sales_expr = [e for e in expressions if e["pattern"] == "/sales/i"][0] + sales_expr = next(e for e in expressions if e["pattern"] == "/sales/i") actions = sales_expr["output"]["action"] # Should have a SWML action with transfer key assert any("SWML" in a for a in actions) - swml_action = [a for a in actions if "SWML" in a][0] + swml_action = next(a for a in actions if "SWML" in a) assert swml_action["transfer"] == "true" # Check the dest in the SWML main_section = swml_action["SWML"]["sections"]["main"] @@ -352,31 +362,26 @@ def test_address_transfer_uses_connect_action(self) -> None: call_args = skill.agent.register_swaig_function.call_args[0][0] expressions = call_args["data_map"]["expressions"] # Find the support expression (address-based) - support_expr = [e for e in expressions if e["pattern"] == "/support/i"][0] + support_expr = next(e for e in expressions if e["pattern"] == "/support/i") actions = support_expr["output"]["action"] - swml_action = [a for a in actions if "SWML" in a][0] + swml_action = next(a for a in actions if "SWML" in a) main_section = swml_action["SWML"]["sections"]["main"] - connect_step = [s for s in main_section if "connect" in s][0] + connect_step = next(s for s in main_section if "connect" in s) assert connect_step["connect"]["to"] == "+15551234567" def test_address_transfer_with_from_addr(self) -> None: """An address config with from_addr should include 'from' in the connect action.""" - transfers = { - "/vip/": { - "address": "+15559876543", - "from_addr": "+15550001111" - } - } + transfers = {"/vip/": {"address": "+15559876543", "from_addr": "+15550001111"}} skill = _make_skill({"transfers": transfers}) skill.setup() skill.register_tools() call_args = skill.agent.register_swaig_function.call_args[0][0] expressions = call_args["data_map"]["expressions"] - vip_expr = [e for e in expressions if e["pattern"] == "/vip/"][0] + vip_expr = next(e for e in expressions if e["pattern"] == "/vip/") actions = vip_expr["output"]["action"] - swml_action = [a for a in actions if "SWML" in a][0] + swml_action = next(a for a in actions if "SWML" in a) main_section = swml_action["SWML"]["sections"]["main"] - connect_step = [s for s in main_section if "connect" in s][0] + connect_step = next(s for s in main_section if "connect" in s) assert connect_step["connect"]["from"] == "+15550001111" def test_address_transfer_non_final(self) -> None: @@ -386,9 +391,9 @@ def test_address_transfer_non_final(self) -> None: skill.register_tools() call_args = skill.agent.register_swaig_function.call_args[0][0] expressions = call_args["data_map"]["expressions"] - support_expr = [e for e in expressions if e["pattern"] == "/support/i"][0] + support_expr = next(e for e in expressions if e["pattern"] == "/support/i") actions = support_expr["output"]["action"] - swml_action = [a for a in actions if "SWML" in a][0] + swml_action = next(a for a in actions if "SWML" in a) assert swml_action["transfer"] == "false" def test_required_fields_added_as_parameters(self) -> None: @@ -434,7 +439,7 @@ def test_register_tools_fallback_without_register_swaig_function(self) -> None: skill = _make_skill() skill.setup() # Remove register_swaig_function so the fallback branch is taken. - delattr(skill.agent, 'register_swaig_function') + delattr(skill.agent, "register_swaig_function") with patch.object(skill, "logger") as mock_logger: skill.register_tools() # The fallback branch logged the missing-method error. @@ -447,6 +452,7 @@ def test_register_tools_fallback_without_register_swaig_function(self) -> None: # get_hints() # --------------------------------------------------------------------------- + class TestGetHints: """Tests for get_hints() method.""" @@ -473,9 +479,7 @@ def test_get_hints_extracts_pattern_names(self) -> None: assert "support" in hints def test_get_hints_handles_pipe_separated_patterns(self) -> None: - transfers = { - "/billing|accounts/i": {"url": "https://example.com/billing"} - } + transfers = {"/billing|accounts/i": {"url": "https://example.com/billing"}} skill = _make_skill({"transfers": transfers}) skill.setup() hints = skill.get_hints() @@ -484,9 +488,7 @@ def test_get_hints_handles_pipe_separated_patterns(self) -> None: def test_get_hints_skips_catch_all_patterns(self) -> None: """Patterns starting with '.' (like '.*') should be skipped.""" - transfers = { - "/.*/": {"url": "https://example.com/fallback"} - } + transfers = {"/.*/": {"url": "https://example.com/fallback"}} skill = _make_skill({"transfers": transfers}) skill.setup() hints = skill.get_hints() @@ -496,9 +498,7 @@ def test_get_hints_skips_catch_all_patterns(self) -> None: def test_get_hints_strips_regex_delimiters(self) -> None: """Leading/trailing slashes should be stripped from patterns.""" - transfers = { - "/billing/": {"url": "https://example.com/billing"} - } + transfers = {"/billing/": {"url": "https://example.com/billing"}} skill = _make_skill({"transfers": transfers}) skill.setup() hints = skill.get_hints() @@ -506,9 +506,7 @@ def test_get_hints_strips_regex_delimiters(self) -> None: def test_get_hints_strips_flags(self) -> None: """Trailing flags like /i should be stripped.""" - transfers = { - "/technical/i": {"url": "https://example.com/tech"} - } + transfers = {"/technical/i": {"url": "https://example.com/tech"}} skill = _make_skill({"transfers": transfers}) skill.setup() hints = skill.get_hints() @@ -519,6 +517,7 @@ def test_get_hints_strips_flags(self) -> None: # get_prompt_sections() # --------------------------------------------------------------------------- + class TestGetPromptSections: """Tests for get_prompt_sections() method.""" @@ -580,7 +579,9 @@ def test_transfer_instructions_bullets_mention_param_name(self) -> None: assert "transfer_type" in combined def test_required_fields_appear_in_instructions(self) -> None: - skill = _make_skill({"required_fields": {"caller_name": "The name of the caller"}}) + skill = _make_skill( + {"required_fields": {"caller_name": "The name of the caller"}} + ) skill.setup() sections = skill.get_prompt_sections() bullets = sections[1]["bullets"] @@ -614,9 +615,7 @@ def test_catch_all_pattern_skipped_in_bullets(self) -> None: def test_address_destination_in_bullets(self) -> None: """Address-based transfers should show the address in bullets.""" - transfers = { - "/helpdesk/": {"address": "+15559990000"} - } + transfers = {"/helpdesk/": {"address": "+15559990000"}} skill = _make_skill({"transfers": transfers}) skill.setup() sections = skill.get_prompt_sections() @@ -629,6 +628,7 @@ def test_address_destination_in_bullets(self) -> None: # get_parameter_schema() # --------------------------------------------------------------------------- + class TestGetParameterSchema: """Tests for get_parameter_schema() classmethod.""" @@ -661,7 +661,10 @@ def test_includes_parameter_description_key(self) -> None: def test_includes_default_message_key(self) -> None: schema = SWMLTransferSkill.get_parameter_schema() assert "default_message" in schema - assert schema["default_message"]["default"] == "Please specify a valid transfer type." + assert ( + schema["default_message"]["default"] + == "Please specify a valid transfer type." + ) def test_includes_default_post_process_key(self) -> None: schema = SWMLTransferSkill.get_parameter_schema() @@ -688,6 +691,7 @@ def test_inherits_tool_name_for_multi_instance(self) -> None: # Edge cases # --------------------------------------------------------------------------- + class TestEdgeCases: """Edge-case and integration tests.""" diff --git a/tests/unit/skills/test_weather_api_skill.py b/tests/unit/skills/test_weather_api_skill.py index 19f718c8..316571ff 100644 --- a/tests/unit/skills/test_weather_api_skill.py +++ b/tests/unit/skills/test_weather_api_skill.py @@ -38,6 +38,7 @@ def _make_skill(params: dict[str, Any] | None = None) -> WeatherApiSkill: # Class-level attributes # --------------------------------------------------------------------------- + class TestWeatherApiSkillClassAttributes: """Verify class-level constants and metadata.""" @@ -45,7 +46,10 @@ def test_skill_name(self) -> None: assert WeatherApiSkill.SKILL_NAME == "weather_api" def test_skill_description(self) -> None: - assert WeatherApiSkill.SKILL_DESCRIPTION == "Get current weather information from WeatherAPI.com" + assert ( + WeatherApiSkill.SKILL_DESCRIPTION + == "Get current weather information from WeatherAPI.com" + ) def test_skill_version(self) -> None: assert WeatherApiSkill.SKILL_VERSION == "1.0.0" @@ -64,6 +68,7 @@ def test_supports_multiple_instances(self) -> None: # Initialization # --------------------------------------------------------------------------- + class TestWeatherApiSkillInit: """Tests for __init__.""" @@ -112,6 +117,7 @@ def test_swaig_fields_default_empty(self) -> None: # _validate_config() # --------------------------------------------------------------------------- + class TestValidateConfig: """Tests for configuration validation.""" @@ -133,7 +139,9 @@ def test_non_string_api_key_raises(self) -> None: def test_invalid_temperature_unit_raises(self) -> None: with pytest.raises(ValueError, match="temperature_unit"): - WeatherApiSkill(agent=Mock(), params={"api_key": "key", "temperature_unit": "kelvin"}) + WeatherApiSkill( + agent=Mock(), params={"api_key": "key", "temperature_unit": "kelvin"} + ) def test_valid_config_does_not_raise(self) -> None: skill = _make_skill() @@ -145,6 +153,7 @@ def test_valid_config_does_not_raise(self) -> None: # setup() # --------------------------------------------------------------------------- + class TestSetup: """Tests for the setup method.""" @@ -161,6 +170,7 @@ def test_setup_returns_true_with_celsius(self) -> None: # register_tools() # --------------------------------------------------------------------------- + class TestRegisterTools: """Tests for register_tools method.""" @@ -198,6 +208,7 @@ def test_register_tools_merges_swaig_fields(self) -> None: # get_tools() # --------------------------------------------------------------------------- + class TestGetTools: """Tests for the get_tools method.""" @@ -328,6 +339,7 @@ def test_fallback_output_contains_error_message(self) -> None: # get_hints() # --------------------------------------------------------------------------- + class TestGetHints: """Tests for the get_hints method.""" @@ -340,6 +352,7 @@ def test_returns_empty_list(self) -> None: # get_prompt_sections() # --------------------------------------------------------------------------- + class TestGetPromptSections: """Tests for the get_prompt_sections method.""" @@ -352,6 +365,7 @@ def test_returns_empty_list(self) -> None: # get_parameter_schema() # --------------------------------------------------------------------------- + class TestGetParameterSchema: """Tests for the class method get_parameter_schema.""" @@ -402,6 +416,7 @@ def test_no_tool_name_from_base_because_single_instance(self) -> None: # get_instance_key() # --------------------------------------------------------------------------- + class TestGetInstanceKey: """Tests for get_instance_key.""" @@ -414,6 +429,7 @@ def test_returns_skill_name_because_single_instance(self) -> None: # Edge cases # --------------------------------------------------------------------------- + class TestEdgeCases: """Edge case tests.""" diff --git a/tests/unit/skills/test_web_search_skill.py b/tests/unit/skills/test_web_search_skill.py index f845cf55..22f04a73 100644 --- a/tests/unit/skills/test_web_search_skill.py +++ b/tests/unit/skills/test_web_search_skill.py @@ -32,14 +32,14 @@ def _make_skill(params: dict[str, Any] | None = None) -> WebSearchSkill: mock_agent = Mock() mock_agent.define_tool = Mock() - skill = WebSearchSkill(agent=mock_agent, params=default_params) - return skill + return WebSearchSkill(agent=mock_agent, params=default_params) # --------------------------------------------------------------------------- # Class-level attributes # --------------------------------------------------------------------------- + class TestWebSearchSkillClassAttributes: """Verify class-level constants and metadata.""" @@ -47,7 +47,10 @@ def test_skill_name(self) -> None: assert WebSearchSkill.SKILL_NAME == "web_search" def test_skill_description(self) -> None: - assert WebSearchSkill.SKILL_DESCRIPTION == "Search the web for information using Google Custom Search API" + assert ( + WebSearchSkill.SKILL_DESCRIPTION + == "Search the web for information using Google Custom Search API" + ) def test_skill_version(self) -> None: assert WebSearchSkill.SKILL_VERSION == "2.0.0" @@ -66,6 +69,7 @@ def test_supports_multiple_instances(self) -> None: # Initialization # --------------------------------------------------------------------------- + class TestWebSearchSkillInit: """Tests for __init__ (inherited from SkillBase).""" @@ -104,6 +108,7 @@ def test_swaig_fields_default_empty(self) -> None: # get_parameter_schema # --------------------------------------------------------------------------- + class TestGetParameterSchema: """Tests for the class method get_parameter_schema.""" @@ -115,8 +120,14 @@ def test_contains_required_params(self) -> None: def test_contains_optional_params(self) -> None: schema = WebSearchSkill.get_parameter_schema() - for key in ("num_results", "delay", "max_content_length", - "oversample_factor", "min_quality_score", "no_results_message"): + for key in ( + "num_results", + "delay", + "max_content_length", + "oversample_factor", + "min_quality_score", + "no_results_message", + ): assert key in schema, f"Missing optional param: {key}" assert schema[key]["required"] is False @@ -204,8 +215,14 @@ def test_every_setup_param_is_advertised(self) -> None: but forgot the schema entry' drift. Every latency/response param read in setup() must appear in the advertised schema.""" schema = WebSearchSkill.get_parameter_schema() - for key in ("response_prefix", "response_postfix", "per_page_timeout", - "overall_deadline", "parallel_scrape", "snippets_only"): + for key in ( + "response_prefix", + "response_postfix", + "per_page_timeout", + "overall_deadline", + "parallel_scrape", + "snippets_only", + ): assert key in schema, f"setup() reads {key!r} but schema omits it" @@ -213,6 +230,7 @@ def test_every_setup_param_is_advertised(self) -> None: # get_instance_key # --------------------------------------------------------------------------- + class TestGetInstanceKey: """Tests for get_instance_key.""" @@ -243,6 +261,7 @@ def test_different_instances_have_different_keys(self) -> None: # setup() # --------------------------------------------------------------------------- + class TestSetup: """Tests for the setup method.""" @@ -272,15 +291,17 @@ def test_setup_optional_defaults(self) -> None: assert "{query}" in skill.no_results_message def test_setup_custom_optional_values(self) -> None: - skill = _make_skill({ - "num_results": 5, - "delay": 1.0, - "max_content_length": 16384, - "oversample_factor": 3.0, - "min_quality_score": 0.5, - "tool_name": "my_search", - "no_results_message": "Nothing found for '{query}'.", - }) + skill = _make_skill( + { + "num_results": 5, + "delay": 1.0, + "max_content_length": 16384, + "oversample_factor": 3.0, + "min_quality_score": 0.5, + "tool_name": "my_search", + "no_results_message": "Nothing found for '{query}'.", + } + ) skill.setup() assert skill.default_num_results == 5 assert skill.default_delay == 1.0 @@ -329,6 +350,7 @@ def test_setup_scraper_max_content_length_passed(self) -> None: # register_tools() # --------------------------------------------------------------------------- + class TestRegisterTools: """Tests for register_tools method.""" @@ -395,6 +417,7 @@ def test_register_tools_description_present(self) -> None: # _web_search_handler() # --------------------------------------------------------------------------- + class TestWebSearchHandler: """Tests for the _web_search_handler method.""" @@ -425,7 +448,9 @@ def test_missing_query_key_returns_error(self) -> None: def test_successful_search(self) -> None: skill = self._setup_skill() mock_results = "Found 2 results meeting quality threshold from 8 searched.\nShowing top 2:\n\n=== RESULT 1 ===\nTitle: Test\nContent: Good content" - with patch.object(skill.search_scraper, 'search_and_scrape_best', return_value=mock_results): + with patch.object( + skill.search_scraper, "search_and_scrape_best", return_value=mock_results + ): result = skill._web_search_handler({"query": "test query"}, {}) assert isinstance(result, FunctionResult) assert "test query" in result.response @@ -433,30 +458,44 @@ def test_successful_search(self) -> None: def test_no_search_results(self) -> None: skill = self._setup_skill() - with patch.object(skill.search_scraper, 'search_and_scrape_best', - return_value="No search results found for query: test"): + with patch.object( + skill.search_scraper, + "search_and_scrape_best", + return_value="No search results found for query: test", + ): result = skill._web_search_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) # Should trigger no_results_message - assert "couldn't find" in result.response.lower() or "quality" in result.response.lower() + assert ( + "couldn't find" in result.response.lower() + or "quality" in result.response.lower() + ) def test_no_quality_results(self) -> None: skill = self._setup_skill() - with patch.object(skill.search_scraper, 'search_and_scrape_best', - return_value="No quality results found for query: test. All results were below quality threshold."): + with patch.object( + skill.search_scraper, + "search_and_scrape_best", + return_value="No quality results found for query: test. All results were below quality threshold.", + ): result = skill._web_search_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) def test_empty_search_results(self) -> None: skill = self._setup_skill() - with patch.object(skill.search_scraper, 'search_and_scrape_best', return_value=""): + with patch.object( + skill.search_scraper, "search_and_scrape_best", return_value="" + ): result = skill._web_search_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) def test_exception_during_search(self) -> None: skill = self._setup_skill() - with patch.object(skill.search_scraper, 'search_and_scrape_best', - side_effect=RuntimeError("connection failed")): + with patch.object( + skill.search_scraper, + "search_and_scrape_best", + side_effect=RuntimeError("connection failed"), + ): result = skill._web_search_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) assert "error" in result.response.lower() @@ -465,33 +504,40 @@ def test_no_results_custom_message_with_placeholder(self) -> None: skill = self._setup_skill( params={"no_results_message": "Sorry, '{query}' not found."} ) - with patch.object(skill.search_scraper, 'search_and_scrape_best', - return_value="No search results found for query: widgets"): + with patch.object( + skill.search_scraper, + "search_and_scrape_best", + return_value="No search results found for query: widgets", + ): result = skill._web_search_handler({"query": "widgets"}, {}) assert result.response == "Sorry, 'widgets' not found." def test_no_results_custom_message_without_placeholder(self) -> None: - skill = self._setup_skill( - params={"no_results_message": "No data available."} - ) - with patch.object(skill.search_scraper, 'search_and_scrape_best', - return_value="No search results found for query: anything"): + skill = self._setup_skill(params={"no_results_message": "No data available."}) + with patch.object( + skill.search_scraper, + "search_and_scrape_best", + return_value="No search results found for query: anything", + ): result = skill._web_search_handler({"query": "anything"}, {}) assert result.response == "No data available." def test_handler_passes_correct_params_to_scraper(self) -> None: - skill = self._setup_skill(params={ - "num_results": 5, - "oversample_factor": 3.0, - "delay": 1.0, - "min_quality_score": 0.5, - "per_page_timeout": 3.5, - "overall_deadline": 12.0, - "parallel_scrape": False, - "snippets_only": True, - }) - with patch.object(skill.search_scraper, 'search_and_scrape_best', - return_value="some results") as mock_search: + skill = self._setup_skill( + params={ + "num_results": 5, + "oversample_factor": 3.0, + "delay": 1.0, + "min_quality_score": 0.5, + "per_page_timeout": 3.5, + "overall_deadline": 12.0, + "parallel_scrape": False, + "snippets_only": True, + } + ) + with patch.object( + skill.search_scraper, "search_and_scrape_best", return_value="some results" + ) as mock_search: skill._web_search_handler({"query": "test"}, {}) mock_search.assert_called_once_with( query="test", @@ -507,33 +553,47 @@ def test_handler_passes_correct_params_to_scraper(self) -> None: def test_handler_strips_query_whitespace(self) -> None: skill = self._setup_skill() - with patch.object(skill.search_scraper, 'search_and_scrape_best', - return_value="some results") as mock_search: + with patch.object( + skill.search_scraper, "search_and_scrape_best", return_value="some results" + ) as mock_search: skill._web_search_handler({"query": " padded query "}, {}) mock_search.assert_called_once() - assert mock_search.call_args[1]["query"] == "padded query" or mock_search.call_args.kwargs["query"] == "padded query" + assert ( + mock_search.call_args[1]["query"] == "padded query" + or mock_search.call_args.kwargs["query"] == "padded query" + ) def test_handler_logs_search_request(self) -> None: skill = self._setup_skill() - with patch.object(skill.search_scraper, 'search_and_scrape_best', return_value="results"): - with patch.object(skill.logger, "info") as mock_info: - skill._web_search_handler({"query": "my search"}, {}) - mock_info.assert_called_once() - assert "my search" in mock_info.call_args[0][0] + with ( + patch.object( + skill.search_scraper, "search_and_scrape_best", return_value="results" + ), + patch.object(skill.logger, "info") as mock_info, + ): + skill._web_search_handler({"query": "my search"}, {}) + mock_info.assert_called_once() + assert "my search" in mock_info.call_args[0][0] def test_handler_logs_error_on_exception(self) -> None: skill = self._setup_skill() - with patch.object(skill.search_scraper, 'search_and_scrape_best', - side_effect=ValueError("bad")): - with patch.object(skill.logger, "error") as mock_error: - skill._web_search_handler({"query": "test"}, {}) - mock_error.assert_called_once() + with ( + patch.object( + skill.search_scraper, + "search_and_scrape_best", + side_effect=ValueError("bad"), + ), + patch.object(skill.logger, "error") as mock_error, + ): + skill._web_search_handler({"query": "test"}, {}) + mock_error.assert_called_once() # --------------------------------------------------------------------------- # GoogleSearchScraper # --------------------------------------------------------------------------- + class TestGoogleSearchScraper: """Tests for the GoogleSearchScraper class.""" @@ -572,12 +632,20 @@ def test_successful_search(self) -> None: mock_response = Mock() mock_response.json.return_value = { "items": [ - {"title": "Result 1", "link": "https://example.com/1", "snippet": "Snippet 1"}, - {"title": "Result 2", "link": "https://example.com/2", "snippet": "Snippet 2"}, + { + "title": "Result 1", + "link": "https://example.com/1", + "snippet": "Snippet 1", + }, + { + "title": "Result 2", + "link": "https://example.com/2", + "snippet": "Snippet 2", + }, ] } mock_response.raise_for_status = Mock() - with patch.object(scraper.session, 'get', return_value=mock_response): + with patch.object(scraper.session, "get", return_value=mock_response): results = scraper.search_google("test query", num_results=5) assert len(results) == 2 assert results[0]["title"] == "Result 1" @@ -589,19 +657,25 @@ def test_search_no_items_key(self) -> None: mock_response = Mock() mock_response.json.return_value = {"searchInformation": {"totalResults": "0"}} mock_response.raise_for_status = Mock() - with patch.object(scraper.session, 'get', return_value=mock_response): + with patch.object(scraper.session, "get", return_value=mock_response): results = scraper.search_google("test query") assert results == [] def test_search_api_error(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") - with patch.object(scraper.session, 'get', side_effect=requests.exceptions.HTTPError("403")): + with patch.object( + scraper.session, "get", side_effect=requests.exceptions.HTTPError("403") + ): results = scraper.search_google("test query") assert results == [] def test_search_network_error(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") - with patch.object(scraper.session, 'get', side_effect=requests.exceptions.ConnectionError("failed")): + with patch.object( + scraper.session, + "get", + side_effect=requests.exceptions.ConnectionError("failed"), + ): results = scraper.search_google("test query") assert results == [] @@ -610,7 +684,9 @@ def test_search_limits_num_results_to_10(self) -> None: mock_response = Mock() mock_response.json.return_value = {"items": []} mock_response.raise_for_status = Mock() - with patch.object(scraper.session, 'get', return_value=mock_response) as mock_get: + with patch.object( + scraper.session, "get", return_value=mock_response + ) as mock_get: scraper.search_google("test", num_results=20) call_kwargs = mock_get.call_args assert call_kwargs[1]["params"]["num"] == 10 @@ -622,7 +698,7 @@ def test_search_missing_fields_defaults_empty(self) -> None: "items": [{"title": "Only Title"}] # missing link and snippet } mock_response.raise_for_status = Mock() - with patch.object(scraper.session, 'get', return_value=mock_response): + with patch.object(scraper.session, "get", return_value=mock_response): results = scraper.search_google("test") assert results[0]["title"] == "Only Title" assert results[0]["url"] == "" @@ -634,14 +710,20 @@ class TestExtractTextFromUrl: def test_routes_reddit_to_extract_reddit_content(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") - with patch.object(scraper, 'extract_reddit_content', return_value=("reddit content", {})) as mock_reddit: - text, _ = scraper.extract_text_from_url("https://www.reddit.com/r/test/comments/123") + with patch.object( + scraper, "extract_reddit_content", return_value=("reddit content", {}) + ) as mock_reddit: + text, _ = scraper.extract_text_from_url( + "https://www.reddit.com/r/test/comments/123" + ) mock_reddit.assert_called_once() assert text == "reddit content" def test_routes_non_reddit_to_extract_html_content(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") - with patch.object(scraper, 'extract_html_content', return_value=("html content", {})) as mock_html: + with patch.object( + scraper, "extract_html_content", return_value=("html content", {}) + ) as mock_html: text, _ = scraper.extract_text_from_url("https://example.com/article") mock_html.assert_called_once() assert text == "html content" @@ -653,17 +735,23 @@ class TestExtractHtmlContent: def test_successful_extraction(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_response = Mock() - mock_response.content = b"
This is quality content with many sentences. " \ - b"It has a lot of text. Multiple lines of content here.
" + mock_response.content = ( + b"
This is quality content with many sentences. " + b"It has a lot of text. Multiple lines of content here.
" + ) mock_response.raise_for_status = Mock() - with patch.object(scraper.session, 'get', return_value=mock_response): + with patch.object(scraper.session, "get", return_value=mock_response): text, metrics = scraper.extract_html_content("https://example.com/article") assert "quality content" in text assert "quality_score" in metrics def test_extraction_error_returns_empty(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") - with patch.object(scraper.session, 'get', side_effect=requests.exceptions.ConnectionError("failed")): + with patch.object( + scraper.session, + "get", + side_effect=requests.exceptions.ConnectionError("failed"), + ): text, metrics = scraper.extract_html_content("https://example.com") assert text == "" assert metrics["quality_score"] == 0 @@ -672,10 +760,12 @@ def test_content_truncation(self) -> None: scraper = GoogleSearchScraper("key", "engine_id", max_content_length=50) mock_response = Mock() long_text = "A" * 200 - mock_response.content = f"
{long_text}
".encode() + mock_response.content = ( + f"
{long_text}
".encode() + ) mock_response.raise_for_status = Mock() - with patch.object(scraper.session, 'get', return_value=mock_response): - text, metrics = scraper.extract_html_content("https://example.com") + with patch.object(scraper.session, "get", return_value=mock_response): + text, _metrics = scraper.extract_html_content("https://example.com") assert len(text) <= 50 def test_custom_content_limit(self) -> None: @@ -684,31 +774,41 @@ def test_custom_content_limit(self) -> None: long_text = "B" * 200 mock_response.content = f"{long_text}".encode() mock_response.raise_for_status = Mock() - with patch.object(scraper.session, 'get', return_value=mock_response): - text, metrics = scraper.extract_html_content("https://example.com", content_limit=30) + with patch.object(scraper.session, "get", return_value=mock_response): + text, _metrics = scraper.extract_html_content( + "https://example.com", content_limit=30 + ) assert len(text) <= 30 class TestExtractRedditContent: """Tests for extract_reddit_content.""" - def _make_reddit_json(self, title: str = "Test Post", author: str = "testuser", - score: int = 100, num_comments: int = 50, - selftext: str = "Post body text", subreddit: str = "test", - comments: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: + def _make_reddit_json( + self, + title: str = "Test Post", + author: str = "testuser", + score: int = 100, + num_comments: int = 50, + selftext: str = "Post body text", + subreddit: str = "test", + comments: list[dict[str, Any]] | None = None, + ) -> list[dict[str, Any]]: """Helper to build Reddit JSON structure.""" post_data = { "data": { - "children": [{ - "data": { - "title": title, - "author": author, - "score": score, - "num_comments": num_comments, - "selftext": selftext, - "subreddit": subreddit, + "children": [ + { + "data": { + "title": title, + "author": author, + "score": score, + "num_comments": num_comments, + "selftext": selftext, + "subreddit": subreddit, + } } - }] + ] } } comments_data: dict[str, Any] @@ -726,7 +826,9 @@ def test_successful_reddit_extraction(self, mock_get: Mock) -> None: mock_response.raise_for_status = Mock() mock_get.return_value = mock_response - text, metrics = scraper.extract_reddit_content("https://reddit.com/r/test/comments/123") + text, metrics = scraper.extract_reddit_content( + "https://reddit.com/r/test/comments/123" + ) assert "Test Post" in text assert "testuser" in text assert metrics["is_reddit"] is True @@ -741,7 +843,7 @@ def test_reddit_with_comments(self, mock_get: Mock) -> None: "body": "This is a very helpful and detailed comment that exceeds the minimum length threshold.", "author": "commenter1", "score": 50, - } + }, } ] mock_response = Mock() @@ -749,7 +851,9 @@ def test_reddit_with_comments(self, mock_get: Mock) -> None: mock_response.raise_for_status = Mock() mock_get.return_value = mock_response - text, metrics = scraper.extract_reddit_content("https://reddit.com/r/test/comments/123") + text, _metrics = scraper.extract_reddit_content( + "https://reddit.com/r/test/comments/123" + ) assert "commenter1" in text assert "helpful" in text @@ -759,7 +863,7 @@ def test_reddit_filters_short_comments(self, mock_get: Mock) -> None: comments = [ { "kind": "t1", - "data": {"body": "Short", "author": "short_commenter", "score": 5} + "data": {"body": "Short", "author": "short_commenter", "score": 5}, } ] mock_response = Mock() @@ -767,7 +871,9 @@ def test_reddit_filters_short_comments(self, mock_get: Mock) -> None: mock_response.raise_for_status = Mock() mock_get.return_value = mock_response - text, _ = scraper.extract_reddit_content("https://reddit.com/r/test/comments/123") + text, _ = scraper.extract_reddit_content( + "https://reddit.com/r/test/comments/123" + ) # Short comments (< 50 chars) should be filtered assert "short_commenter" not in text @@ -781,7 +887,7 @@ def test_reddit_filters_deleted_comments(self, mock_get: Mock) -> None: "body": "[deleted]", "author": "deleted_user", "score": 100, - } + }, } ] mock_response = Mock() @@ -789,7 +895,9 @@ def test_reddit_filters_deleted_comments(self, mock_get: Mock) -> None: mock_response.raise_for_status = Mock() mock_get.return_value = mock_response - text, _ = scraper.extract_reddit_content("https://reddit.com/r/test/comments/123") + text, _ = scraper.extract_reddit_content( + "https://reddit.com/r/test/comments/123" + ) assert "deleted_user" not in text @patch("signalwire.skills.web_search.skill.requests.get") @@ -800,7 +908,9 @@ def test_reddit_filters_removed_selftext(self, mock_get: Mock) -> None: mock_response.raise_for_status = Mock() mock_get.return_value = mock_response - text, _ = scraper.extract_reddit_content("https://reddit.com/r/test/comments/123") + text, _ = scraper.extract_reddit_content( + "https://reddit.com/r/test/comments/123" + ) assert "[removed]" not in text @patch("signalwire.skills.web_search.skill.requests.get") @@ -832,7 +942,9 @@ def test_reddit_invalid_json_falls_back_to_html(self, mock_get: Mock) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_get.side_effect = ValueError("Invalid JSON") - with patch.object(scraper, 'extract_html_content', return_value=("fallback", {})) as mock_html: + with patch.object( + scraper, "extract_html_content", return_value=("fallback", {}) + ) as mock_html: text, _ = scraper.extract_reddit_content("https://reddit.com/r/test") mock_html.assert_called_once() assert text == "fallback" @@ -845,18 +957,24 @@ def test_reddit_content_limit(self, mock_get: Mock) -> None: mock_response.raise_for_status = Mock() mock_get.return_value = mock_response - text, _ = scraper.extract_reddit_content("https://reddit.com/r/test", content_limit=50) + text, _ = scraper.extract_reddit_content( + "https://reddit.com/r/test", content_limit=50 + ) assert len(text) <= 50 @patch("signalwire.skills.web_search.skill.requests.get") def test_reddit_quality_metrics(self, mock_get: Mock) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_response = Mock() - mock_response.json.return_value = self._make_reddit_json(score=200, num_comments=100) + mock_response.json.return_value = self._make_reddit_json( + score=200, num_comments=100 + ) mock_response.raise_for_status = Mock() mock_get.return_value = mock_response - _, metrics = scraper.extract_reddit_content("https://reddit.com/r/test/comments/123") + _, metrics = scraper.extract_reddit_content( + "https://reddit.com/r/test/comments/123" + ) assert "quality_score" in metrics assert metrics["is_reddit"] is True assert metrics["score"] == 200 @@ -873,14 +991,20 @@ def test_empty_text_zero_quality(self) -> None: def test_short_text_low_quality(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") - metrics = scraper._calculate_content_quality("Short text", "https://example.com") + metrics = scraper._calculate_content_quality( + "Short text", "https://example.com" + ) assert metrics["quality_score"] < 0.5 def test_quality_domain_bonus(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") text = "A " * 2000 # Enough text - metrics_quality = scraper._calculate_content_quality(text, "https://wikipedia.org/wiki/test") - metrics_generic = scraper._calculate_content_quality(text, "https://randomsite.com/page") + metrics_quality = scraper._calculate_content_quality( + text, "https://wikipedia.org/wiki/test" + ) + metrics_generic = scraper._calculate_content_quality( + text, "https://randomsite.com/page" + ) assert metrics_quality["domain_score"] > metrics_generic["domain_score"] def test_low_quality_domain_penalty(self) -> None: @@ -892,21 +1016,34 @@ def test_low_quality_domain_penalty(self) -> None: def test_boilerplate_penalty(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") text_clean = "This is good content about programming. " * 100 - text_boilerplate = "cookie privacy policy terms of service subscribe sign up " * 50 - metrics_clean = scraper._calculate_content_quality(text_clean, "https://example.com") - metrics_boilerplate = scraper._calculate_content_quality(text_boilerplate, "https://example.com") - assert metrics_clean["boilerplate_penalty"] > metrics_boilerplate["boilerplate_penalty"] + text_boilerplate = ( + "cookie privacy policy terms of service subscribe sign up " * 50 + ) + metrics_clean = scraper._calculate_content_quality( + text_clean, "https://example.com" + ) + metrics_boilerplate = scraper._calculate_content_quality( + text_boilerplate, "https://example.com" + ) + assert ( + metrics_clean["boilerplate_penalty"] + > metrics_boilerplate["boilerplate_penalty"] + ) def test_query_relevance_scoring(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") text = "Python programming language is great for data science and machine learning tasks." - metrics = scraper._calculate_content_quality(text, "https://example.com", query="Python programming") + metrics = scraper._calculate_content_quality( + text, "https://example.com", query="Python programming" + ) assert metrics["query_relevance"] > 0 def test_no_query_neutral_relevance(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") text = "Some content here." - metrics = scraper._calculate_content_quality(text, "https://example.com", query="") + metrics = scraper._calculate_content_quality( + text, "https://example.com", query="" + ) assert metrics["query_relevance"] == 0.5 @@ -915,7 +1052,7 @@ class TestSearchAndScrapeBest: def test_no_search_results(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") - with patch.object(scraper, 'search_google', return_value=[]): + with patch.object(scraper, "search_google", return_value=[]): result = scraper.search_and_scrape_best("test query") assert "No search results found" in result @@ -928,33 +1065,43 @@ def test_all_results_below_threshold_falls_back_to_snippets(self) -> None: search_results = [ {"title": "Bad", "url": "https://bad.com", "snippet": "bad snippet text"} ] - with patch.object(scraper, 'search_google', return_value=search_results): - with patch.object(scraper, 'extract_text_from_url', return_value=("", {"quality_score": 0})): - result = scraper.search_and_scrape_best("test query", delay=0, - parallel_scrape=False) - # Snippet fallback: non-empty, carries the snippet + title. - assert "Snippet-only results" in result - assert "bad snippet text" in result - assert "No quality results found" not in result + with ( + patch.object(scraper, "search_google", return_value=search_results), + patch.object( + scraper, + "extract_text_from_url", + return_value=("", {"quality_score": 0}), + ), + ): + result = scraper.search_and_scrape_best( + "test query", delay=0, parallel_scrape=False + ) + # Snippet fallback: non-empty, carries the snippet + title. + assert "Snippet-only results" in result + assert "bad snippet text" in result + assert "No quality results found" not in result def test_snippets_only_skips_scraping(self) -> None: # snippets_only short-circuits before any page fetch. scraper = GoogleSearchScraper("key", "engine_id") - search_results = [ - {"title": "T", "url": "https://x.com", "snippet": "snip"} - ] - with patch.object(scraper, 'search_google', return_value=search_results): - with patch.object(scraper, 'extract_text_from_url') as mock_extract: - result = scraper.search_and_scrape_best("test query", - snippets_only=True) - mock_extract.assert_not_called() - assert "Snippet-only results" in result - assert "snip" in result + search_results = [{"title": "T", "url": "https://x.com", "snippet": "snip"}] + with ( + patch.object(scraper, "search_google", return_value=search_results), + patch.object(scraper, "extract_text_from_url") as mock_extract, + ): + result = scraper.search_and_scrape_best("test query", snippets_only=True) + mock_extract.assert_not_called() + assert "Snippet-only results" in result + assert "snip" in result def test_successful_search_and_scrape(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") search_results = [ - {"title": "Good Result", "url": "https://example.com/good", "snippet": "A good result"} + { + "title": "Good Result", + "url": "https://example.com/good", + "snippet": "A good result", + } ] good_metrics = { "quality_score": 0.8, @@ -964,13 +1111,20 @@ def test_successful_search_and_scrape(self) -> None: "query_relevance": 0.9, "query_words_found": "2/2", } - with patch.object(scraper, 'search_google', return_value=search_results): - with patch.object(scraper, 'extract_text_from_url', - return_value=("Great content here", good_metrics)): - with patch.object(scraper, '_calculate_content_quality', return_value=good_metrics): - result = scraper.search_and_scrape_best("test query", delay=0) - assert "RESULT 1" in result - assert "Good Result" in result + with ( + patch.object(scraper, "search_google", return_value=search_results), + patch.object( + scraper, + "extract_text_from_url", + return_value=("Great content here", good_metrics), + ), + patch.object( + scraper, "_calculate_content_quality", return_value=good_metrics + ), + ): + result = scraper.search_and_scrape_best("test query", delay=0) + assert "RESULT 1" in result + assert "Good Result" in result def test_domain_diversity(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") @@ -979,10 +1133,22 @@ def test_domain_diversity(self) -> None: {"title": "Result A2", "url": "https://a.com/2", "snippet": "A2"}, {"title": "Result B1", "url": "https://b.com/1", "snippet": "B1"}, ] - metrics_a = {"quality_score": 0.9, "domain": "a.com", "text_length": 5000, - "sentence_count": 10, "query_relevance": 0.8, "query_words_found": "1/1"} - metrics_b = {"quality_score": 0.7, "domain": "b.com", "text_length": 5000, - "sentence_count": 10, "query_relevance": 0.8, "query_words_found": "1/1"} + metrics_a = { + "quality_score": 0.9, + "domain": "a.com", + "text_length": 5000, + "sentence_count": 10, + "query_relevance": 0.8, + "query_words_found": "1/1", + } + metrics_b = { + "quality_score": 0.7, + "domain": "b.com", + "text_length": 5000, + "sentence_count": 10, + "query_relevance": 0.8, + "query_words_found": "1/1", + } def mock_extract(url: str, **kwargs: Any) -> tuple[str, dict[str, Any]]: if "a.com" in url: @@ -994,18 +1160,24 @@ def mock_quality(text: str, url: str, query: str = "") -> dict[str, Any]: return metrics_a return metrics_b - with patch.object(scraper, 'search_google', return_value=search_results): - with patch.object(scraper, 'extract_text_from_url', side_effect=mock_extract): - with patch.object(scraper, '_calculate_content_quality', side_effect=mock_quality): - result = scraper.search_and_scrape_best("test", num_results=2, delay=0) - # Should show results from both domains - assert "a.com" in result - assert "b.com" in result + with ( + patch.object(scraper, "search_google", return_value=search_results), + patch.object(scraper, "extract_text_from_url", side_effect=mock_extract), + patch.object( + scraper, "_calculate_content_quality", side_effect=mock_quality + ), + ): + result = scraper.search_and_scrape_best("test", num_results=2, delay=0) + # Should show results from both domains + assert "a.com" in result + assert "b.com" in result def test_backward_compatible_search_and_scrape(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") - with patch.object(scraper, 'search_and_scrape_best', return_value="results") as mock_best: - result = scraper.search_and_scrape("test query", num_results=2, delay=0.1) + with patch.object( + scraper, "search_and_scrape_best", return_value="results" + ) as mock_best: + scraper.search_and_scrape("test query", num_results=2, delay=0.1) mock_best.assert_called_once_with( query="test query", num_results=2, @@ -1019,6 +1191,7 @@ def test_backward_compatible_search_and_scrape(self) -> None: # get_hints() # --------------------------------------------------------------------------- + class TestGetHints: """Tests for the get_hints method.""" @@ -1031,6 +1204,7 @@ def test_returns_empty_list(self) -> None: # get_global_data() # --------------------------------------------------------------------------- + class TestGetGlobalData: """Tests for the get_global_data method.""" @@ -1047,6 +1221,7 @@ def test_returns_correct_keys(self) -> None: # get_prompt_sections() # --------------------------------------------------------------------------- + class TestGetPromptSections: """Tests for the get_prompt_sections method.""" @@ -1087,6 +1262,7 @@ def test_section_has_bullets(self) -> None: # Edge cases and integration-style tests # --------------------------------------------------------------------------- + class TestEdgeCases: """Edge case and integration-style tests.""" @@ -1100,8 +1276,11 @@ def test_setup_then_register_then_handler_flow(self) -> None: _, kw = skill.agent.define_tool.call_args handler = kw["handler"] - with patch.object(skill.search_scraper, 'search_and_scrape_best', - return_value="=== RESULT 1 ===\nTitle: Lifecycle\nContent: Answer"): + with patch.object( + skill.search_scraper, + "search_and_scrape_best", + return_value="=== RESULT 1 ===\nTitle: Lifecycle\nContent: Answer", + ): result = handler({"query": "lifecycle test"}, {}) assert isinstance(result, FunctionResult) assert "Lifecycle" in result.response @@ -1129,7 +1308,9 @@ def test_handler_with_none_return_from_scraper(self) -> None: """If scraper returns None, handler should handle gracefully.""" skill = _make_skill() skill.setup() - with patch.object(skill.search_scraper, 'search_and_scrape_best', return_value=None): + with patch.object( + skill.search_scraper, "search_and_scrape_best", return_value=None + ): result = skill._web_search_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) diff --git a/tests/unit/skills/test_wikipedia_search_skill.py b/tests/unit/skills/test_wikipedia_search_skill.py index a784ba24..17be1a38 100644 --- a/tests/unit/skills/test_wikipedia_search_skill.py +++ b/tests/unit/skills/test_wikipedia_search_skill.py @@ -21,6 +21,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_skill(params: dict[str, Any] | None = None) -> WikipediaSearchSkill: """Create a WikipediaSearchSkill instance with a mocked agent.""" mock_agent = Mock() @@ -39,40 +40,24 @@ def _setup_skill(params: dict[str, Any] | None = None) -> WikipediaSearchSkill: def _mock_search_response(titles: list[str]) -> dict[str, Any]: """Build a mock JSON response for Wikipedia search API.""" - return { - "query": { - "search": [{"title": t} for t in titles] - } - } + return {"query": {"search": [{"title": t} for t in titles]}} def _mock_extract_response(title: str, extract: str) -> dict[str, Any]: """Build a mock JSON response for Wikipedia extract API.""" - return { - "query": { - "pages": { - "12345": { - "title": title, - "extract": extract - } - } - } - } + return {"query": {"pages": {"12345": {"title": title, "extract": extract}}}} def _mock_extract_response_empty_pages() -> dict[str, Any]: """Build a mock JSON response with no pages.""" - return { - "query": { - "pages": {} - } - } + return {"query": {"pages": {}}} # =========================================================================== # Class-Level Metadata # =========================================================================== + class TestWikipediaSearchSkillMetadata: """Verify class-level attributes and metadata.""" @@ -101,6 +86,7 @@ def test_supports_multiple_instances(self) -> None: # Initialization # =========================================================================== + class TestWikipediaSearchSkillInit: """Test __init__ behaviour inherited from SkillBase.""" @@ -135,6 +121,7 @@ def test_init_logger_name(self) -> None: # get_parameter_schema # =========================================================================== + class TestParameterSchema: """Test the get_parameter_schema class method.""" @@ -170,6 +157,7 @@ def test_no_tool_name_for_single_instance_skill(self) -> None: # setup() # =========================================================================== + class TestSetup: """Test the setup() method.""" @@ -229,8 +217,10 @@ def test_setup_returns_false_when_packages_missing(self) -> None: def test_setup_logs_info(self) -> None: skill = _make_skill({"num_results": 3}) - with patch.object(skill, "validate_packages", return_value=True), \ - patch.object(skill.logger, "info") as mock_info: + with ( + patch.object(skill, "validate_packages", return_value=True), + patch.object(skill.logger, "info") as mock_info, + ): skill.setup() mock_info.assert_called_once() assert "3" in mock_info.call_args[0][0] @@ -240,6 +230,7 @@ def test_setup_logs_info(self) -> None: # register_tools() # =========================================================================== + class TestRegisterTools: """Test the register_tools() method.""" @@ -252,14 +243,18 @@ def test_register_tools_tool_name(self) -> None: skill = _setup_skill() skill.register_tools() kwargs = skill.agent.define_tool.call_args - assert kwargs[1]["name"] == "search_wiki" or kwargs.kwargs["name"] == "search_wiki" + assert ( + kwargs[1]["name"] == "search_wiki" or kwargs.kwargs["name"] == "search_wiki" + ) def test_register_tools_tool_has_query_parameter(self) -> None: skill = _setup_skill() skill.register_tools() call_kwargs = skill.agent.define_tool.call_args # define_tool is called via self.define_tool which merges swaig_fields - params = call_kwargs.kwargs.get("parameters") or call_kwargs[1].get("parameters") + params = call_kwargs.kwargs.get("parameters") or call_kwargs[1].get( + "parameters" + ) assert "query" in params assert params["query"]["type"] == "string" @@ -279,14 +274,16 @@ def test_register_tools_with_swaig_fields(self) -> None: skill.register_tools() call_kwargs = skill.agent.define_tool.call_args # The merged kwargs should include the swaig_fields - assert call_kwargs.kwargs.get("meta_data") == {"token": "abc"} or \ - call_kwargs[1].get("meta_data") == {"token": "abc"} + assert call_kwargs.kwargs.get("meta_data") == {"token": "abc"} or call_kwargs[ + 1 + ].get("meta_data") == {"token": "abc"} # =========================================================================== # _search_wiki_handler() # =========================================================================== + class TestSearchWikiHandler: """Test the _search_wiki_handler method.""" @@ -312,7 +309,9 @@ def test_handler_missing_query_key(self, mock_get: Mock) -> None: mock_get.assert_not_called() assert result.response == "Please provide a search query for Wikipedia." - @patch.object(WikipediaSearchSkill, "search_wiki", return_value="Python is a language.") + @patch.object( + WikipediaSearchSkill, "search_wiki", return_value="Python is a language." + ) def test_handler_delegates_to_search_wiki(self, mock_search: Mock) -> None: skill = _setup_skill() result = skill._search_wiki_handler({"query": "Python"}, {}) @@ -322,6 +321,7 @@ def test_handler_delegates_to_search_wiki(self, mock_search: Mock) -> None: @patch.object(WikipediaSearchSkill, "search_wiki", return_value="Some content") def test_handler_returns_swaig_function_result(self, mock_search: Mock) -> None: from signalwire.core.function_result import FunctionResult + skill = _setup_skill() result = skill._search_wiki_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) @@ -337,6 +337,7 @@ def test_handler_strips_query(self, mock_search: Mock) -> None: # search_wiki() -- Single Result # =========================================================================== + class TestSearchWikiSingleResult: """Test search_wiki with a single result (default num_results=1).""" @@ -345,13 +346,15 @@ def test_single_result_success(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() - search_resp.json.return_value = _mock_search_response(["Python (programming language)"]) + search_resp.json.return_value = _mock_search_response( + ["Python (programming language)"] + ) search_resp.raise_for_status = Mock() extract_resp = Mock() extract_resp.json.return_value = _mock_extract_response( "Python (programming language)", - "Python is a high-level programming language." + "Python is a high-level programming language.", ) extract_resp.raise_for_status = Mock() @@ -405,6 +408,7 @@ def test_timeout_is_10_seconds(self, mock_get: Mock) -> None: # search_wiki() -- Multiple Results # =========================================================================== + class TestSearchWikiMultipleResults: """Test search_wiki when num_results > 1.""" @@ -413,15 +417,21 @@ def test_multiple_results_joined_with_separator(self, mock_get: Mock) -> None: skill = _setup_skill({"num_results": 2}) search_resp = Mock() - search_resp.json.return_value = _mock_search_response(["Article One", "Article Two"]) + search_resp.json.return_value = _mock_search_response( + ["Article One", "Article Two"] + ) search_resp.raise_for_status = Mock() extract_resp_1 = Mock() - extract_resp_1.json.return_value = _mock_extract_response("Article One", "Content one.") + extract_resp_1.json.return_value = _mock_extract_response( + "Article One", "Content one." + ) extract_resp_1.raise_for_status = Mock() extract_resp_2 = Mock() - extract_resp_2.json.return_value = _mock_extract_response("Article Two", "Content two.") + extract_resp_2.json.return_value = _mock_extract_response( + "Article Two", "Content two." + ) extract_resp_2.raise_for_status = Mock() mock_get.side_effect = [search_resp, extract_resp_1, extract_resp_2] @@ -437,7 +447,9 @@ def test_results_limited_to_num_results(self, mock_get: Mock) -> None: skill = _setup_skill({"num_results": 1}) search_resp = Mock() - search_resp.json.return_value = _mock_search_response(["Title A", "Title B", "Title C"]) + search_resp.json.return_value = _mock_search_response( + ["Title A", "Title B", "Title C"] + ) search_resp.raise_for_status = Mock() extract_resp = Mock() @@ -456,6 +468,7 @@ def test_results_limited_to_num_results(self, mock_get: Mock) -> None: # search_wiki() -- No Results / Empty Content # =========================================================================== + class TestSearchWikiNoResults: """Test search_wiki edge cases for empty or missing data.""" @@ -557,6 +570,7 @@ def test_custom_no_results_message_with_query(self, mock_get: Mock) -> None: # search_wiki() -- Error Handling # =========================================================================== + class TestSearchWikiErrorHandling: """Test error handling in search_wiki.""" @@ -581,7 +595,9 @@ def test_timeout_exception_on_search(self, mock_get: Mock) -> None: def test_http_error_on_search(self, mock_get: Mock) -> None: skill = _setup_skill() resp = Mock() - resp.raise_for_status.side_effect = requests.exceptions.HTTPError("500 Server Error") + resp.raise_for_status.side_effect = requests.exceptions.HTTPError( + "500 Server Error" + ) mock_get.return_value = resp result = skill.search_wiki("test") @@ -645,6 +661,7 @@ def test_json_decode_error_on_extract(self, mock_get: Mock) -> None: # search_wiki() -- Response structure edge cases # =========================================================================== + class TestSearchWikiResponseStructure: """Test subtle response structure edge cases.""" @@ -658,7 +675,9 @@ def test_single_article_no_separator(self, mock_get: Mock) -> None: search_resp.raise_for_status = Mock() extract_resp = Mock() - extract_resp.json.return_value = _mock_extract_response("Only One", "Content here.") + extract_resp.json.return_value = _mock_extract_response( + "Only One", "Content here." + ) extract_resp.raise_for_status = Mock() mock_get.side_effect = [search_resp, extract_resp] @@ -668,7 +687,9 @@ def test_single_article_no_separator(self, mock_get: Mock) -> None: assert result == "**Only One**\n\nContent here." @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_extract_with_leading_trailing_whitespace_stripped(self, mock_get: Mock) -> None: + def test_extract_with_leading_trailing_whitespace_stripped( + self, mock_get: Mock + ) -> None: skill = _setup_skill() search_resp = Mock() @@ -692,11 +713,15 @@ def test_multiple_results_some_empty_extracts(self, mock_get: Mock) -> None: skill = _setup_skill({"num_results": 2}) search_resp = Mock() - search_resp.json.return_value = _mock_search_response(["Full Article", "Empty Article"]) + search_resp.json.return_value = _mock_search_response( + ["Full Article", "Empty Article"] + ) search_resp.raise_for_status = Mock() extract_resp_1 = Mock() - extract_resp_1.json.return_value = _mock_extract_response("Full Article", "Has content.") + extract_resp_1.json.return_value = _mock_extract_response( + "Full Article", "Has content." + ) extract_resp_1.raise_for_status = Mock() extract_resp_2 = Mock() @@ -745,6 +770,7 @@ def test_page_missing_extract_key(self, mock_get: Mock) -> None: # get_prompt_sections() # =========================================================================== + class TestGetPromptSections: """Test get_prompt_sections method.""" @@ -785,6 +811,7 @@ def test_section_bullets_mention_search_wiki(self) -> None: # get_hints() # =========================================================================== + class TestGetHints: """Test get_hints method.""" @@ -802,6 +829,7 @@ def test_returns_list_type(self) -> None: # get_instance_key() # =========================================================================== + class TestGetInstanceKey: """Test instance key behaviour for single-instance skill.""" @@ -819,6 +847,7 @@ def test_ignores_tool_name_param(self) -> None: # Integration-style tests (handler -> search_wiki flow) # =========================================================================== + class TestHandlerToSearchIntegration: """Test the full handler -> search_wiki pipeline with mocked HTTP.""" @@ -833,7 +862,7 @@ def test_full_flow_success(self, mock_get: Mock) -> None: extract_resp = Mock() extract_resp.json.return_value = _mock_extract_response( "Albert Einstein", - "Albert Einstein was a German-born theoretical physicist." + "Albert Einstein was a German-born theoretical physicist.", ) extract_resp.raise_for_status = Mock() diff --git a/tests/unit/test_optional_extra_import_errors.py b/tests/unit/test_optional_extra_import_errors.py new file mode 100644 index 00000000..6dc12b95 --- /dev/null +++ b/tests/unit/test_optional_extra_import_errors.py @@ -0,0 +1,105 @@ +""" +Copyright (c) 2025 SignalWire + +This file is part of the SignalWire SDK. + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. + +Optional-extra import guards. + +Modules that are only usable when an optional extra is installed must fail with +a message naming the ``pip install signalwire-sdk[]`` command that +supplies them -- never with a bare ``ModuleNotFoundError: No module named +'flask'``, which tells the user nothing about how to fix it. + +These tests simulate the third-party package being absent (they do NOT require +it to actually be uninstalled), so they are meaningful on a developer box whose +venv happens to have every extra installed. +""" + +import builtins +import importlib +import sys +from collections.abc import Iterator +from contextlib import contextmanager + +import pytest + + +@contextmanager +def hidden_modules(*prefixes: str) -> Iterator[None]: + """Make ``import `` (and any submodule) raise ModuleNotFoundError. + + Both the already-imported entries in ``sys.modules`` and any fresh import + attempt are blocked, so a module re-imported inside the block sees the + packages as genuinely absent. + """ + + def blocked(name: str) -> bool: + return any(name == p or name.startswith(p + ".") for p in prefixes) + + saved = {k: v for k, v in sys.modules.items() if blocked(k)} + for k in saved: + del sys.modules[k] + + real_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): # type: ignore[no-untyped-def] + if blocked(name): + raise ModuleNotFoundError(f"No module named {name.split('.')[0]!r}") + return real_import(name, globals, locals, fromlist, level) + + builtins.__import__ = fake_import + try: + yield + finally: + builtins.__import__ = real_import + sys.modules.update(saved) + + +def reimport(module_name: str) -> None: + """Force a fresh top-level execution of ``module_name``.""" + sys.modules.pop(module_name, None) + importlib.import_module(module_name) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("module_name", "hidden", "extra"), + [ + ( + "signalwire.mcp_gateway.gateway_service", + ("flask", "flask_limiter", "werkzeug"), + "mcp-gateway", + ), + ("signalwire.search.query_processor", ("nltk",), "search"), + ], +) +def test_missing_extra_names_the_pip_install_command( + module_name: str, hidden: tuple[str, ...], extra: str +) -> None: + """The raised error must tell the user which extra to install.""" + original = sys.modules.get(module_name) + try: + with hidden_modules(*hidden), pytest.raises(ImportError) as excinfo: + reimport(module_name) + message = str(excinfo.value) + assert f"signalwire-sdk[{extra}]" in message, ( + f"{module_name} raised {message!r}, which does not name the " + f"'{extra}' extra -- the user is left with a bare " + f"ModuleNotFoundError and no way to know the fix." + ) + assert "pip install" in message + finally: + sys.modules.pop(module_name, None) + if original is not None: + sys.modules[module_name] = original + + +@pytest.mark.unit +def test_guard_is_transparent_when_the_extra_is_installed() -> None: + """With the extra present the module imports normally (no false failure).""" + pytest.importorskip("flask") + pytest.importorskip("flask_limiter") + reimport("signalwire.mcp_gateway.gateway_service") diff --git a/tests/unit/utils/test_execution_mode.py b/tests/unit/utils/test_execution_mode.py index 097c7810..83c6e40a 100644 --- a/tests/unit/utils/test_execution_mode.py +++ b/tests/unit/utils/test_execution_mode.py @@ -12,7 +12,6 @@ import os from unittest.mock import patch -import pytest from signalwire.core.logging_config import get_execution_mode from signalwire.utils import is_serverless_mode @@ -23,9 +22,13 @@ def test_default_is_server(self) -> None: # Clear all detected env vars; should default to "server". env_keys = [ "GATEWAY_INTERFACE", - "AWS_LAMBDA_FUNCTION_NAME", "LAMBDA_TASK_ROOT", - "FUNCTION_TARGET", "K_SERVICE", "GOOGLE_CLOUD_PROJECT", - "AZURE_FUNCTIONS_ENVIRONMENT", "FUNCTIONS_WORKER_RUNTIME", + "AWS_LAMBDA_FUNCTION_NAME", + "LAMBDA_TASK_ROOT", + "FUNCTION_TARGET", + "K_SERVICE", + "GOOGLE_CLOUD_PROJECT", + "AZURE_FUNCTIONS_ENVIRONMENT", + "FUNCTIONS_WORKER_RUNTIME", "AzureWebJobsStorage", ] with patch.dict(os.environ, {}, clear=False): @@ -50,15 +53,25 @@ def test_lambda_detected_via_task_root(self) -> None: def test_google_cloud_function_detected(self) -> None: with patch.dict(os.environ, {"FUNCTION_TARGET": "my_handler"}, clear=False): - for k in ("GATEWAY_INTERFACE", "AWS_LAMBDA_FUNCTION_NAME", "LAMBDA_TASK_ROOT"): + for k in ( + "GATEWAY_INTERFACE", + "AWS_LAMBDA_FUNCTION_NAME", + "LAMBDA_TASK_ROOT", + ): os.environ.pop(k, None) assert get_execution_mode() == "google_cloud_function" def test_azure_function_detected(self) -> None: - with patch.dict(os.environ, {"AZURE_FUNCTIONS_ENVIRONMENT": "Production"}, clear=False): + with patch.dict( + os.environ, {"AZURE_FUNCTIONS_ENVIRONMENT": "Production"}, clear=False + ): for k in ( - "GATEWAY_INTERFACE", "AWS_LAMBDA_FUNCTION_NAME", "LAMBDA_TASK_ROOT", - "FUNCTION_TARGET", "K_SERVICE", "GOOGLE_CLOUD_PROJECT", + "GATEWAY_INTERFACE", + "AWS_LAMBDA_FUNCTION_NAME", + "LAMBDA_TASK_ROOT", + "FUNCTION_TARGET", + "K_SERVICE", + "GOOGLE_CLOUD_PROJECT", ): os.environ.pop(k, None) assert get_execution_mode() == "azure_function" @@ -68,9 +81,13 @@ class TestIsServerlessMode: def test_server_mode_is_not_serverless(self) -> None: env_keys = [ "GATEWAY_INTERFACE", - "AWS_LAMBDA_FUNCTION_NAME", "LAMBDA_TASK_ROOT", - "FUNCTION_TARGET", "K_SERVICE", "GOOGLE_CLOUD_PROJECT", - "AZURE_FUNCTIONS_ENVIRONMENT", "FUNCTIONS_WORKER_RUNTIME", + "AWS_LAMBDA_FUNCTION_NAME", + "LAMBDA_TASK_ROOT", + "FUNCTION_TARGET", + "K_SERVICE", + "GOOGLE_CLOUD_PROJECT", + "AZURE_FUNCTIONS_ENVIRONMENT", + "FUNCTIONS_WORKER_RUNTIME", "AzureWebJobsStorage", ] with patch.dict(os.environ, {}, clear=False): diff --git a/tests/unit/utils/test_schema_anyof.py b/tests/unit/utils/test_schema_anyof.py new file mode 100644 index 00000000..8a01403e --- /dev/null +++ b/tests/unit/utils/test_schema_anyof.py @@ -0,0 +1,257 @@ +""" +Copyright (c) 2025 SignalWire + +This file is part of the SignalWire SDK. + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. +""" + +""" +The shallow closed-key check and anyOf/oneOf-shaped verb configs. + +``_verb_top_level_property_names`` used to test ``body.get("type") != "object"`` +on the verb's config node and bail otherwise. A union node (``{"anyOf": [...]}``) +carries no ``type`` of its own, so that test failed and the resolver returned +None — which ``_validate_verb_top_level_keys`` reads as "no key-set to enforce" +and answers valid for ANY key. The check did not report a problem; it stopped +checking and reported success, which is the worse of the two. + +Five verbs in the SHIPPED schema.json are union-shaped — connect and play (oneOf +of $refs), send_sms (anyOf of $refs), sleep (anyOf of an object / integer / +SWMLVar), and unset (anyOf of string / array). Four of the five have object +branches whose keys are perfectly enumerable. + +The semantic: a config satisfying a union satisfies SOME branch, so the known +keys are the UNION of the object branches' keys, and a key belonging to no branch +belongs to no valid document. Non-object branches contribute nothing (they +constrain the config to not be an object at all — a different question). ``unset`` +has no object branch, so it correctly stays disengaged. + +NOTE on reachability in this port: ``add_verb`` routes a verb with a registered +HANDLER to this shallow resolver and everything else to the deep full-JSON-Schema +validator. ``ai`` is the only registered handler, and it is a plain closed object, +so the union verbs do not reach the shallow resolver today — the deep path +rejects a stray key. The resolver defect is therefore LATENT in python: it goes +live the moment any union-shaped verb gets a handler. These tests exercise the +resolver directly, which is where the defect lives. +""" + +from typing import Any + +import pytest + +from signalwire.utils.schema_utils import SchemaUtils + + +# The verb configs the shipped schema expresses as an anyOf/oneOf, with the key +# set the union must resolve to and a legitimate config that must keep passing. +UNION_SHAPED_VERBS: list[tuple[str, str, dict[str, Any], int]] = [ + ("sleep", "duration", {"duration": 5000}, 1), + ("play", "url", {"url": "https://example.test/a.mp3"}, 8), + ( + "send_sms", + "body", + {"to_number": "+15551110000", "from_number": "+15552220000", "body": "hi"}, + 6, + ), + ("connect", "to", {"to": "sip:alice@example.test"}, 22), +] + +# Shapes that genuinely have no closed key-set, so the fix is not read as +# "always enforce something": +# set -- an OPEN object (unevaluatedProperties:{} with no `not`, zero declared +# properties): a free-form variable bag by design. +# unset -- a union with no object branch (string | array of string). +# cond / label / return -- array / string / untyped, not objects at all. +NON_ENUMERABLE_VERBS = ["set", "unset", "cond", "label", "return"] + + +@pytest.fixture(scope="module") +def schema_utils() -> SchemaUtils: + """The real shipped schema — this defect is about the vendored schema.json, + not a synthetic fixture.""" + return SchemaUtils() + + +class TestUnionShapedVerbs: + """The union-shaped verb configs the resolver used to bail on.""" + + @pytest.mark.parametrize( + "verb,want_key,legit,want_count", + UNION_SHAPED_VERBS, + ids=[v[0] for v in UNION_SHAPED_VERBS], + ) + def test_union_shaped_verbs_resolve_a_key_set( + self, + schema_utils: SchemaUtils, + verb: str, + want_key: str, + legit: dict[str, Any], + want_count: int, + ) -> None: + """The direct negative control: before the fix every one of these + resolved to None, i.e. the closed-key check was disengaged on them.""" + known = schema_utils._verb_top_level_property_names(verb) + assert known is not None, ( + f"{verb}: closed-key check DISENGAGED on a union-shaped config; " + "it must resolve to the union of the object branches' keys" + ) + assert want_key in known, ( + f"{verb}: resolved key set is missing {want_key!r}; got {sorted(known)}" + ) + assert len(known) == want_count, ( + f"{verb}: resolved {len(known)} keys, want {want_count}: {sorted(known)}" + ) + + @pytest.mark.parametrize( + "verb,want_key,legit,want_count", + UNION_SHAPED_VERBS, + ids=[v[0] for v in UNION_SHAPED_VERBS], + ) + def test_union_shaped_verbs_reject_unknown_keys( + self, + schema_utils: SchemaUtils, + verb: str, + want_key: str, + legit: dict[str, Any], + want_count: int, + ) -> None: + """The forbidden-key direction: a key present in no branch must be + rejected. Every one of these was ACCEPTED before the fix.""" + cfg = dict(legit) + cfg["zzz_not_a_real_key"] = 1 + is_valid, errors = schema_utils._validate_verb_top_level_keys(verb, cfg) + assert not is_valid, ( + f"{verb}: a key present in no branch was ACCEPTED — the closed-key " + "check is disengaged on this union-shaped config" + ) + assert "zzz_not_a_real_key" in " ".join(errors), ( + f"{verb}: rejection must name the offending key; got {errors}" + ) + + @pytest.mark.parametrize( + "verb,want_key,legit,want_count", + UNION_SHAPED_VERBS, + ids=[v[0] for v in UNION_SHAPED_VERBS], + ) + def test_union_shaped_verbs_accept_legitimate_configs( + self, + schema_utils: SchemaUtils, + verb: str, + want_key: str, + legit: dict[str, Any], + want_count: int, + ) -> None: + """The other direction — the fix must not start rejecting valid + documents. A branch set computed as an INTERSECTION would fail here, + since a key valid in one branch is absent from the others.""" + is_valid, errors = schema_utils._validate_verb_top_level_keys(verb, legit) + assert is_valid, f"{verb}: legitimate config rejected: {errors}" + + @pytest.mark.parametrize( + "discriminator,value", + [ + ("to", "sip:alice@example.test"), + ("serial", [{"to": "sip:a@example.test"}]), + ("parallel", [{"to": "sip:a@example.test"}]), + ("serial_parallel", [[{"to": "sip:a@example.test"}]]), + ], + ) + def test_connect_branch_discriminators_all_accepted( + self, schema_utils: SchemaUtils, discriminator: str, value: Any + ) -> None: + """The union direction tested explicitly rather than only in aggregate: + connect's four ConnectDevice branches differ only in their discriminating + key, and all four must be accepted — a branch set computed as an + INTERSECTION would reject three of them.""" + is_valid, errors = schema_utils._validate_verb_top_level_keys( + "connect", {discriminator: value} + ) + assert is_valid, ( + f"connect: branch discriminator {discriminator!r} rejected — the " + f"branch key sets look INTERSECTED, not unioned: {errors}" + ) + + +class TestNonEnumerableConfigsStayDisengaged: + """Pins the shapes that genuinely have no closed key set, so nothing is + weakened and the fix is not read as 'always enforce something'.""" + + @pytest.mark.parametrize("verb", NON_ENUMERABLE_VERBS) + def test_resolver_stays_disengaged( + self, schema_utils: SchemaUtils, verb: str + ) -> None: + assert schema_utils._verb_top_level_property_names(verb) is None, ( + f"{verb} has no closed key-set in the schema; the shallow check must " + "stay disengaged rather than invent one" + ) + + @pytest.mark.parametrize("verb", NON_ENUMERABLE_VERBS) + def test_disengaged_check_is_a_no_op_not_a_rejection( + self, schema_utils: SchemaUtils, verb: str + ) -> None: + is_valid, errors = schema_utils._validate_verb_top_level_keys( + verb, {"anything": 1} + ) + assert is_valid, f"{verb}: disengaged check must pass, got {errors}" + + +class TestRefFollowingStillWorks: + """Guards the shape the resolver already handled — a single $ref + (ai -> AIObject) — since the fix rewrote that path into the shared recursive + resolver.""" + + def test_ai_ref_resolves(self, schema_utils: SchemaUtils) -> None: + known = schema_utils._verb_top_level_property_names("ai") + assert known is not None, ( + "ai: $ref to AIObject must still resolve to a closed key set" + ) + for want in ["prompt", "params", "SWAIG"]: + assert want in known, f"ai: resolved key set is missing {want!r}" + + +class TestEngagedVerbCount: + """The aggregate the fix moves: 30 -> 34 engaged verbs, with the four + newly-engaged ones named. An aggregate-only assertion would let a resolver + that engaged the WRONG four pass, so both are pinned.""" + + def test_engaged_count_and_membership(self, schema_utils: SchemaUtils) -> None: + engaged = { + verb + for verb in schema_utils.verbs + if schema_utils._verb_top_level_property_names(verb) is not None + } + disengaged = set(schema_utils.verbs) - engaged + + assert len(engaged) == 34, ( + f"expected 34 engaged verbs, got {len(engaged)}; " + f"disengaged = {sorted(disengaged)}" + ) + # The four union-shaped verbs with object branches, which the pre-fix + # resolver bailed on. + for verb in ["sleep", "play", "send_sms", "connect"]: + assert verb in engaged, f"{verb} must be engaged after the fix" + # Nothing may be weakened: these five have no closed key-set. + assert disengaged == set(NON_ENUMERABLE_VERBS), ( + f"the disengaged set must be exactly {sorted(NON_ENUMERABLE_VERBS)}, " + f"got {sorted(disengaged)}" + ) + + +class TestResolverTerminates: + """The depth bound: a self-referential $ref must not spin the resolver.""" + + def test_self_referential_ref_terminates(self) -> None: + su = SchemaUtils() + # A node that $refs a $def which $refs itself. Without the depth bound + # this recurses until the interpreter's stack limit. + su.schema.setdefault("$defs", {})["SelfRef"] = {"$ref": "#/$defs/SelfRef"} + assert su._closed_key_set({"$ref": "#/$defs/SelfRef"}, 0) is None + + def test_self_referential_union_terminates(self) -> None: + su = SchemaUtils() + su.schema.setdefault("$defs", {})["SelfUnion"] = { + "anyOf": [{"$ref": "#/$defs/SelfUnion"}] + } + assert su._closed_key_set({"$ref": "#/$defs/SelfUnion"}, 0) is None diff --git a/tests/unit/utils/test_schema_utils.py b/tests/unit/utils/test_schema_utils.py index b68bf1a4..1cacd979 100644 --- a/tests/unit/utils/test_schema_utils.py +++ b/tests/unit/utils/test_schema_utils.py @@ -11,11 +11,11 @@ Unit tests for schema_utils module """ -import pytest import json import os import tempfile from pathlib import Path + try: # Python 3.11+: the Traversable ABC lives under importlib.resources.abc. from importlib.resources.abc import Traversable @@ -23,70 +23,87 @@ # Python 3.10: importlib.resources.abc does not exist yet; the ABC is at # importlib.abc.Traversable (used here only as a type annotation). from importlib.abc import Traversable -from unittest.mock import Mock, patch, MagicMock, mock_open -from typing import Dict, List, Any, Optional +from unittest.mock import Mock, patch +from typing import Any from signalwire.utils.schema_utils import SchemaUtils class TestSchemaUtils: """Test SchemaUtils functionality""" - + def test_basic_initialization_with_schema_path(self) -> None: """Test basic SchemaUtils initialization with schema path""" - with patch.object(SchemaUtils, 'load_schema', return_value={}): - with patch.object(SchemaUtils, '_extract_verb_definitions', return_value={}): - utils = SchemaUtils(schema_path="/path/to/schema.json") - - assert utils.schema_path == "/path/to/schema.json" - assert utils.schema == {} - assert utils.verbs == {} - + with ( + patch.object(SchemaUtils, "load_schema", return_value={}), + patch.object(SchemaUtils, "_extract_verb_definitions", return_value={}), + ): + utils = SchemaUtils(schema_path="/path/to/schema.json") + + assert utils.schema_path == "/path/to/schema.json" + assert utils.schema == {} + assert utils.verbs == {} + def test_initialization_without_schema_path(self) -> None: """Test initialization without schema path uses default""" - with patch.object(SchemaUtils, '_get_default_schema_path', return_value="/default/schema.json"): - with patch.object(SchemaUtils, 'load_schema', return_value={}): - with patch.object(SchemaUtils, '_extract_verb_definitions', return_value={}): - utils = SchemaUtils() - - assert utils.schema_path == "/default/schema.json" - + with ( + patch.object( + SchemaUtils, + "_get_default_schema_path", + return_value="/default/schema.json", + ), + patch.object(SchemaUtils, "load_schema", return_value={}), + patch.object(SchemaUtils, "_extract_verb_definitions", return_value={}), + ): + utils = SchemaUtils() + + assert utils.schema_path == "/default/schema.json" + def test_get_default_schema_path_importlib_resources_new(self) -> None: """Test default schema path using importlib.resources (Python 3.13+)""" utils = SchemaUtils.__new__(SchemaUtils) # Create without calling __init__ - + mock_path = Mock() mock_path.__str__ = Mock(return_value="/package/schema.json") # type: ignore[method-assign] - - with patch('importlib.resources.files') as mock_files: + + with patch("importlib.resources.files") as mock_files: mock_files.return_value.joinpath.return_value = mock_path - + result = utils._get_default_schema_path() - + assert result == "/package/schema.json" mock_files.assert_called_once_with("signalwire") - + def test_get_default_schema_path_importlib_resources_old(self) -> None: """Test default schema path using importlib.resources (Python 3.7-3.8)""" utils = SchemaUtils.__new__(SchemaUtils) - - with patch('importlib.resources.files', side_effect=AttributeError): - with patch('importlib.resources.path') as mock_path: - mock_context = Mock() - mock_context.__enter__ = Mock(return_value=Path("/old/schema.json")) - mock_context.__exit__ = Mock(return_value=None) - mock_path.return_value = mock_context - - result = utils._get_default_schema_path() - - assert result == "/old/schema.json" - + + # The resource is a Path, and the method returns str(path) — so the + # expected value must be built the same way rather than hardcoded as a + # POSIX literal. str(Path("/old/schema.json")) is "\old\schema.json" on + # Windows, which is correct behavior, not a bug. + resource = Path("/old") / "schema.json" + + with ( + patch("importlib.resources.files", side_effect=AttributeError), + patch("importlib.resources.path") as mock_path, + ): + mock_context = Mock() + mock_context.__enter__ = Mock(return_value=resource) + mock_context.__exit__ = Mock(return_value=None) + mock_path.return_value = mock_context + + result = utils._get_default_schema_path() + + assert result == str(resource) + def test_get_default_schema_path_manual_search(self) -> None: """Test default schema path using manual file search when importlib.resources fails""" utils = SchemaUtils.__new__(SchemaUtils) utils.log = Mock() import importlib.resources + original_files = importlib.resources.files def failing_files(package: str) -> Traversable: @@ -94,15 +111,25 @@ def failing_files(package: str) -> Traversable: raise ImportError("mocked") return original_files(package) - with patch('importlib.resources.files', side_effect=failing_files): - with patch('os.path.exists') as mock_exists: - with patch('os.getcwd', return_value="/current"): - # First path exists - mock_exists.side_effect = lambda path: path == "/current/schema.json" + # The product composes candidates with os.path.join(os.getcwd(), ...), + # which yields "/current\schema.json" on Windows. Build the expected + # string with the same join so the test asserts the contract (the cwd + # candidate is searched first and returned) instead of a POSIX-only + # spelling of it. + cwd = os.path.join(os.sep, "current") # noqa: PTH118 + expected = os.path.join(cwd, "schema.json") # noqa: PTH118 - result = utils._get_default_schema_path() + with ( + patch("importlib.resources.files", side_effect=failing_files), + patch("os.path.exists") as mock_exists, + patch("os.getcwd", return_value=cwd), + ): + # First path exists + mock_exists.side_effect = lambda path: path == expected + + result = utils._get_default_schema_path() - assert result == "/current/schema.json" + assert result == expected def test_get_default_schema_path_not_found(self) -> None: """Test default schema path when file is not found anywhere""" @@ -110,6 +137,7 @@ def test_get_default_schema_path_not_found(self) -> None: utils.log = Mock() import importlib.resources + original_files = importlib.resources.files def failing_files(package: str) -> Traversable: @@ -117,87 +145,82 @@ def failing_files(package: str) -> Traversable: raise ImportError("mocked") return original_files(package) - with patch('importlib.resources.files', side_effect=failing_files): - with patch('os.path.exists', return_value=False): + with ( + patch("importlib.resources.files", side_effect=failing_files), + patch("os.path.exists", return_value=False), + ): + result = utils._get_default_schema_path() - result = utils._get_default_schema_path() + assert result is None - assert result is None - def test_load_schema_success(self) -> None: """Test successful schema loading""" schema_data = { "$defs": { - "SWMLMethod": { - "anyOf": [{"$ref": "#/$defs/AIMethod"}] - }, - "AIMethod": { - "properties": { - "ai": {"type": "object"} - } - } + "SWMLMethod": {"anyOf": [{"$ref": "#/$defs/AIMethod"}]}, + "AIMethod": {"properties": {"ai": {"type": "object"}}}, } } - - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(schema_data, f) schema_path = f.name - + try: utils = SchemaUtils.__new__(SchemaUtils) utils.schema_path = schema_path utils.log = Mock() - + result = utils.load_schema() - + assert result == schema_data finally: - os.unlink(schema_path) - + Path(schema_path).unlink() + def test_load_schema_file_not_found(self) -> None: """Test schema loading when file doesn't exist""" utils = SchemaUtils.__new__(SchemaUtils) utils.schema_path = "/nonexistent/schema.json" utils.log = Mock() - + result = utils.load_schema() - + assert result == {} utils.log.error.assert_called_once() - + def test_load_schema_invalid_json(self) -> None: """Test schema loading with invalid JSON""" - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: f.write("invalid json content") schema_path = f.name - + try: utils = SchemaUtils.__new__(SchemaUtils) utils.schema_path = schema_path utils.log = Mock() - + result = utils.load_schema() - + assert result == {} utils.log.error.assert_called_once() finally: - os.unlink(schema_path) - + Path(schema_path).unlink() + def test_load_schema_no_path(self) -> None: """Test schema loading when no path is provided""" utils = SchemaUtils.__new__(SchemaUtils) utils.schema_path = None utils.log = Mock() - + result = utils.load_schema() - + assert result == {} utils.log.warning.assert_called_once() class TestVerbExtraction: """Test verb extraction functionality""" - + def test_extract_verb_definitions_success(self) -> None: """Test successful verb extraction""" schema = { @@ -205,16 +228,14 @@ def test_extract_verb_definitions_success(self) -> None: "SWMLMethod": { "anyOf": [ {"$ref": "#/$defs/AIMethod"}, - {"$ref": "#/$defs/AnswerMethod"} + {"$ref": "#/$defs/AnswerMethod"}, ] }, "AIMethod": { "properties": { "ai": { "type": "object", - "properties": { - "prompt": {"type": "string"} - } + "properties": {"prompt": {"type": "string"}}, } } }, @@ -222,63 +243,55 @@ def test_extract_verb_definitions_success(self) -> None: "properties": { "answer": { "type": "object", - "properties": { - "max_duration": {"type": "integer"} - } + "properties": {"max_duration": {"type": "integer"}}, } } - } + }, } } - + utils = SchemaUtils.__new__(SchemaUtils) utils.schema = schema utils.log = Mock() - + verbs = utils._extract_verb_definitions() - + assert "ai" in verbs assert "answer" in verbs assert verbs["ai"]["name"] == "ai" assert verbs["ai"]["schema_name"] == "AIMethod" assert verbs["answer"]["name"] == "answer" assert verbs["answer"]["schema_name"] == "AnswerMethod" - + def test_extract_verb_definitions_no_swml_method(self) -> None: """Test verb extraction when SWMLMethod is missing""" schema: dict[str, Any] = {"$defs": {}} - + utils = SchemaUtils.__new__(SchemaUtils) utils.schema = schema utils.log = Mock() - + verbs = utils._extract_verb_definitions() - + assert verbs == {} utils.log.warning.assert_called_once() - + def test_extract_verb_definitions_no_anyof(self) -> None: """Test verb extraction when anyOf is missing""" - schema: dict[str, Any] = { - "$defs": { - "SWMLMethod": { - "properties": {} - } - } - } - + schema: dict[str, Any] = {"$defs": {"SWMLMethod": {"properties": {}}}} + utils = SchemaUtils.__new__(SchemaUtils) utils.schema = schema utils.log = Mock() - + verbs = utils._extract_verb_definitions() - + assert verbs == {} class TestVerbProperties: """Test verb property access methods""" - + def setup_method(self) -> None: """Set up test data""" self.utils = SchemaUtils.__new__(SchemaUtils) @@ -292,12 +305,12 @@ def setup_method(self) -> None: "type": "object", "properties": { "prompt": {"type": "string"}, - "temperature": {"type": "number"} + "temperature": {"type": "number"}, }, - "required": ["prompt"] + "required": ["prompt"], } } - } + }, }, "answer": { "name": "answer", @@ -306,73 +319,68 @@ def setup_method(self) -> None: "properties": { "answer": { "type": "object", - "properties": { - "max_duration": {"type": "integer"} - } + "properties": {"max_duration": {"type": "integer"}}, } } - } - } + }, + }, } - + def test_get_verb_properties_existing(self) -> None: """Test getting properties for existing verb""" result = self.utils.get_verb_properties("ai") - + expected = { "type": "object", "properties": { "prompt": {"type": "string"}, - "temperature": {"type": "number"} + "temperature": {"type": "number"}, }, - "required": ["prompt"] + "required": ["prompt"], } assert result == expected - + def test_get_verb_properties_nonexistent(self) -> None: """Test getting properties for nonexistent verb""" result = self.utils.get_verb_properties("nonexistent") - + assert result == {} - + def test_get_verb_required_properties_existing(self) -> None: """Test getting required properties for existing verb""" result = self.utils.get_verb_required_properties("ai") - + assert result == ["prompt"] - + def test_get_verb_required_properties_no_required(self) -> None: """Test getting required properties when none are specified""" result = self.utils.get_verb_required_properties("answer") - + assert result == [] - + def test_get_verb_required_properties_nonexistent(self) -> None: """Test getting required properties for nonexistent verb""" result = self.utils.get_verb_required_properties("nonexistent") - + assert result == [] - + def test_get_all_verb_names(self) -> None: """Test getting all verb names""" result = self.utils.get_all_verb_names() - + assert set(result) == {"ai", "answer"} - + def test_get_verb_parameters_existing(self) -> None: """Test getting parameters for existing verb""" result = self.utils.get_verb_parameters("ai") - - expected = { - "prompt": {"type": "string"}, - "temperature": {"type": "number"} - } + + expected = {"prompt": {"type": "string"}, "temperature": {"type": "number"}} assert result == expected - + def test_get_verb_parameters_nonexistent(self) -> None: """Test getting parameters for nonexistent verb""" result = self.utils.get_verb_parameters("nonexistent") - + assert result == {} @@ -395,57 +403,57 @@ def setup_method(self) -> None: "type": "object", "properties": { "prompt": {"type": "string"}, - "temperature": {"type": "number"} + "temperature": {"type": "number"}, }, - "required": ["prompt"] + "required": ["prompt"], } } - } + }, } } - + def test_validate_verb_valid_config(self) -> None: """Test validation with valid configuration""" config = {"prompt": "You are helpful"} - + is_valid, errors = self.utils.validate_verb("ai", config) - + assert is_valid is True assert errors == [] - + def test_validate_verb_missing_required(self) -> None: """Test validation with missing required property""" config = {"temperature": 0.7} - + is_valid, errors = self.utils.validate_verb("ai", config) - + assert is_valid is False assert len(errors) == 1 assert "Missing required property 'prompt'" in errors[0] - + def test_validate_verb_nonexistent_verb(self) -> None: """Test validation with nonexistent verb""" config = {"some": "config"} - + is_valid, errors = self.utils.validate_verb("nonexistent", config) - + assert is_valid is False assert len(errors) == 1 assert "Unknown verb: nonexistent" in errors[0] - + def test_validate_verb_extra_properties_allowed(self) -> None: """Test validation allows extra properties""" config = {"prompt": "You are helpful", "extra_prop": "value"} - + is_valid, errors = self.utils.validate_verb("ai", config) - + assert is_valid is True assert errors == [] class TestCodeGeneration: """Test code generation functionality""" - + def setup_method(self) -> None: """Set up test data""" self.utils = SchemaUtils.__new__(SchemaUtils) @@ -460,132 +468,127 @@ def setup_method(self) -> None: "properties": { "prompt": { "type": "string", - "description": "The AI prompt text" + "description": "The AI prompt text", }, "temperature": { "type": "number", - "description": "Temperature for AI generation" - } + "description": "Temperature for AI generation", + }, }, - "required": ["prompt"] + "required": ["prompt"], } } - } + }, } } - + def test_generate_method_signature(self) -> None: """Test method signature generation""" result = self.utils.generate_method_signature("ai") - - assert "def ai(self, prompt: str, temperature: Optional[float] = None, **kwargs) -> bool:" in result + + assert ( + "def ai(self, prompt: str, temperature: Optional[float] = None, **kwargs) -> bool:" + in result + ) assert "Add the ai verb to the current document" in result assert "prompt: The AI prompt text" in result assert "temperature: Temperature for AI generation" in result - + def test_generate_method_body(self) -> None: """Test method body generation""" result = self.utils.generate_method_body("ai") - + assert "config = {}" in result assert "if prompt is not None:" in result assert "config['prompt'] = prompt" in result assert "if temperature is not None:" in result assert "config['temperature'] = temperature" in result assert "return self.add_verb('ai', config)" in result - + def test_get_type_annotation_string(self) -> None: """Test type annotation for string""" param_def = {"type": "string"} - + result = self.utils._get_type_annotation(param_def) - + assert result == "str" - + def test_get_type_annotation_integer(self) -> None: """Test type annotation for integer""" param_def = {"type": "integer"} - + result = self.utils._get_type_annotation(param_def) - + assert result == "int" - + def test_get_type_annotation_number(self) -> None: """Test type annotation for number""" param_def = {"type": "number"} - + result = self.utils._get_type_annotation(param_def) - + assert result == "float" - + def test_get_type_annotation_boolean(self) -> None: """Test type annotation for boolean""" param_def = {"type": "boolean"} - + result = self.utils._get_type_annotation(param_def) - + assert result == "bool" - + def test_get_type_annotation_array(self) -> None: """Test type annotation for array""" - param_def = { - "type": "array", - "items": {"type": "string"} - } - + param_def = {"type": "array", "items": {"type": "string"}} + result = self.utils._get_type_annotation(param_def) - + assert result == "List[str]" - + def test_get_type_annotation_array_no_items(self) -> None: """Test type annotation for array without items""" param_def = {"type": "array"} - + result = self.utils._get_type_annotation(param_def) - + assert result == "List[Any]" - + def test_get_type_annotation_object(self) -> None: """Test type annotation for object""" param_def = {"type": "object"} - + result = self.utils._get_type_annotation(param_def) - + assert result == "Dict[str, Any]" - + def test_get_type_annotation_anyof(self) -> None: """Test type annotation for anyOf""" - param_def = { - "anyOf": [ - {"type": "string"}, - {"type": "integer"} - ] - } - + param_def = {"anyOf": [{"type": "string"}, {"type": "integer"}]} + result = self.utils._get_type_annotation(param_def) - + assert result == "Any" - + def test_get_type_annotation_ref(self) -> None: """Test type annotation for $ref""" param_def = {"$ref": "#/$defs/SomeType"} - + result = self.utils._get_type_annotation(param_def) - + assert result == "Any" - + def test_get_type_annotation_unknown(self) -> None: """Test type annotation for unknown type""" param_def = {"type": "unknown"} - + result = self.utils._get_type_annotation(param_def) - + assert result == "Any" class TestSchemaUtilsIntegration: """Test integration scenarios""" - + def test_complete_workflow(self) -> None: """Test complete schema utils workflow""" schema_data = { @@ -593,7 +596,7 @@ def test_complete_workflow(self) -> None: "SWMLMethod": { "anyOf": [ {"$ref": "#/$defs/AIMethod"}, - {"$ref": "#/$defs/PlayMethod"} + {"$ref": "#/$defs/PlayMethod"}, ] }, "AIMethod": { @@ -603,14 +606,14 @@ def test_complete_workflow(self) -> None: "properties": { "prompt": { "type": "string", - "description": "AI prompt" + "description": "AI prompt", }, "temperature": { "type": "number", - "description": "Generation temperature" - } + "description": "Generation temperature", + }, }, - "required": ["prompt"] + "required": ["prompt"], } } }, @@ -619,71 +622,75 @@ def test_complete_workflow(self) -> None: "play": { "type": "object", "properties": { - "url": { - "type": "string", - "description": "URL to play" - } + "url": {"type": "string", "description": "URL to play"} }, - "required": ["url"] + "required": ["url"], } } - } + }, } } - - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(schema_data, f) schema_path = f.name - + try: # Initialize with schema utils = SchemaUtils(schema_path) - + # Test verb discovery verb_names = utils.get_all_verb_names() assert "ai" in verb_names assert "play" in verb_names - + # Test validation valid_config = {"prompt": "Hello"} is_valid, errors = utils.validate_verb("ai", valid_config) assert is_valid is True - + assert errors == [] + invalid_config: dict[str, Any] = {} is_valid, errors = utils.validate_verb("ai", invalid_config) assert is_valid is False - + # An invalid config must SAY why — an empty error list would make the + # False above unactionable for a caller. + assert errors + # Test code generation signature = utils.generate_method_signature("ai") assert "def ai(" in signature - + body = utils.generate_method_body("ai") assert "add_verb('ai'" in body - + finally: - os.unlink(schema_path) - + Path(schema_path).unlink() + def test_error_recovery(self) -> None: """Test error recovery scenarios""" # Test with invalid schema path utils = SchemaUtils("/nonexistent/schema.json") - + # Should still work with empty schema assert utils.get_all_verb_names() == [] - + # Validation should fail gracefully is_valid, errors = utils.validate_verb("ai", {}) assert is_valid is False assert "Unknown verb" in errors[0] - + def test_empty_schema_handling(self) -> None: """Test handling of empty schema""" - with patch.object(SchemaUtils, 'load_schema', return_value={}): + with patch.object(SchemaUtils, "load_schema", return_value={}): utils = SchemaUtils("/path/to/schema.json") - + assert utils.get_all_verb_names() == [] assert utils.get_verb_properties("ai") == {} assert utils.get_verb_parameters("ai") == {} - + is_valid, errors = utils.validate_verb("ai", {}) - assert is_valid is False \ No newline at end of file + assert is_valid is False + # With an empty schema the verb is unknown; the failure must be + # reported, not returned as a bare False. + assert errors diff --git a/tests/unit/utils/test_url_validator.py b/tests/unit/utils/test_url_validator.py index af4f5fc6..44025c5a 100644 --- a/tests/unit/utils/test_url_validator.py +++ b/tests/unit/utils/test_url_validator.py @@ -11,7 +11,6 @@ import os from unittest.mock import patch -import pytest from signalwire.utils.url_validator import validate_url @@ -42,13 +41,16 @@ def test_no_hostname_rejected(self) -> None: def test_hostname_unresolvable_rejected(self) -> None: import socket as _socket + with patch("socket.getaddrinfo", side_effect=_socket.gaierror): assert validate_url("http://nonexistent.invalid") is False class TestValidateUrlBlockedRanges: def test_loopback_ipv4_rejected(self) -> None: - with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("127.0.0.1", 0))]): + with patch( + "socket.getaddrinfo", return_value=[(0, 0, 0, "", ("127.0.0.1", 0))] + ): assert validate_url("http://localhost") is False def test_rfc1918_10_rejected(self) -> None: @@ -56,12 +58,16 @@ def test_rfc1918_10_rejected(self) -> None: assert validate_url("http://internal") is False def test_rfc1918_192_rejected(self) -> None: - with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("192.168.1.1", 0))]): + with patch( + "socket.getaddrinfo", return_value=[(0, 0, 0, "", ("192.168.1.1", 0))] + ): assert validate_url("http://router") is False def test_link_local_metadata_rejected(self) -> None: # 169.254.169.254 is the AWS/GCP metadata endpoint - with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("169.254.169.254", 0))]): + with patch( + "socket.getaddrinfo", return_value=[(0, 0, 0, "", ("169.254.169.254", 0))] + ): assert validate_url("http://metadata") is False def test_ipv6_loopback_rejected(self) -> None: @@ -83,6 +89,8 @@ def test_env_var_bypasses_check(self) -> None: assert validate_url("http://10.0.0.5") is True def test_env_var_false_does_not_bypass(self) -> None: - with patch.dict(os.environ, {"SWML_ALLOW_PRIVATE_URLS": "false"}, clear=False): - with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("10.0.0.5", 0))]): - assert validate_url("http://internal") is False + with ( + patch.dict(os.environ, {"SWML_ALLOW_PRIVATE_URLS": "false"}, clear=False), + patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("10.0.0.5", 0))]), + ): + assert validate_url("http://internal") is False diff --git a/tests/unit/web/test_web_service.py b/tests/unit/web/test_web_service.py index 187d7e43..f5d9b85a 100644 --- a/tests/unit/web/test_web_service.py +++ b/tests/unit/web/test_web_service.py @@ -33,9 +33,10 @@ # --------------------------------------------------------------------------- -# Helpers – build a minimally-patched WebService instance +# Helpers - build a minimally-patched WebService instance # --------------------------------------------------------------------------- + def _make_security_mock() -> MagicMock: """Return a mock SecurityConfig that satisfies WebService.__init__.""" sec = MagicMock() @@ -85,9 +86,7 @@ def _make_web_service( } if not fastapi_available: - patches["fastapi_mod"] = patch( - "signalwire.web.web_service.FastAPI", None - ) + patches["fastapi_mod"] = patch("signalwire.web.web_service.FastAPI", None) started = {k: p.start() for k, p in patches.items()} @@ -120,6 +119,7 @@ def _stop_patches(ws: Any) -> None: # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture() def web_service() -> Iterator[Any]: ws = _make_web_service() @@ -145,6 +145,7 @@ def web_service_with_browsing() -> Iterator[Any]: # Initialization tests # --------------------------------------------------------------------------- + class TestWebServiceInit: """Tests for __init__ and _load_config.""" @@ -154,9 +155,9 @@ def test_default_port(self, web_service: Any) -> None: def test_default_directories_empty(self, web_service: Any) -> None: assert web_service.directories == {} - def test_custom_directories(self) -> None: - ws = _make_web_service(directories={"/static": "/tmp"}) - assert ws.directories == {"/static": "/tmp"} + def test_custom_directories(self, tmp_path: Path) -> None: + ws = _make_web_service(directories={"/static": str(tmp_path)}) + assert ws.directories == {"/static": str(tmp_path)} _stop_patches(ws) def test_enable_directory_browsing_default_off(self, web_service: Any) -> None: @@ -174,8 +175,18 @@ def test_custom_max_file_size(self) -> None: _stop_patches(ws) def test_default_blocked_extensions(self, web_service: Any) -> None: - for ext in [".env", ".git", ".gitignore", ".key", ".pem", ".crt", - ".pyc", "__pycache__", ".DS_Store", ".swp"]: + for ext in [ + ".env", + ".git", + ".gitignore", + ".key", + ".pem", + ".crt", + ".pyc", + "__pycache__", + ".DS_Store", + ".swp", + ]: assert ext in web_service.blocked_extensions def test_custom_blocked_extensions(self) -> None: @@ -195,7 +206,9 @@ def test_app_is_created_when_fastapi_available(self, web_service: Any) -> None: # When FastAPI is importable the app attribute should not be None assert web_service.app is not None - def test_app_is_none_when_fastapi_unavailable(self, web_service_no_fastapi: Any) -> None: + def test_app_is_none_when_fastapi_unavailable( + self, web_service_no_fastapi: Any + ) -> None: assert web_service_no_fastapi.app is None def test_basic_auth_from_constructor(self) -> None: @@ -220,11 +233,12 @@ def test_enable_cors_false(self) -> None: # _load_config tests # --------------------------------------------------------------------------- + class TestLoadConfig: """Tests for _load_config with mocked ConfigLoader.""" def test_no_config_file_sets_defaults(self, web_service: Any) -> None: - # Already exercised in fixture – directories default to empty dict + # Already exercised in fixture - directories default to empty dict assert isinstance(web_service.directories, dict) def test_config_with_service_section(self) -> None: @@ -242,15 +256,19 @@ def test_config_with_service_section(self) -> None: mock_loader_instance.has_config.return_value = True mock_loader_instance.get_section.return_value = service_section - with patch( - "signalwire.web.web_service.SecurityConfig", - return_value=_make_security_mock(), - ), patch( - "signalwire.web.web_service.ConfigLoader.find_config_file", - return_value="/fake/config.json", - ), patch( - "signalwire.web.web_service.ConfigLoader", - return_value=mock_loader_instance, + with ( + patch( + "signalwire.web.web_service.SecurityConfig", + return_value=_make_security_mock(), + ), + patch( + "signalwire.web.web_service.ConfigLoader.find_config_file", + return_value="/fake/config.json", + ), + patch( + "signalwire.web.web_service.ConfigLoader", + return_value=mock_loader_instance, + ), ): from signalwire.web.web_service import WebService @@ -269,6 +287,7 @@ def test_config_with_service_section(self) -> None: # _is_file_allowed tests # --------------------------------------------------------------------------- + class TestIsFileAllowed: """Tests for the _is_file_allowed method.""" @@ -385,6 +404,7 @@ def test_custom_blocked_extensions(self, tmp_path: Path) -> None: # Path traversal protection # --------------------------------------------------------------------------- + class TestPathTraversalProtection: """Verify that directory traversal attacks are blocked. @@ -403,7 +423,9 @@ def test_traversal_dot_dot_blocked(self, tmp_path: Path) -> None: full_path = (Path(str(base_dir)) / file_path).resolve() dir_path = Path(str(base_dir)).resolve() - within = str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path + within = ( + str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path + ) assert within is False, "Path traversal must be detected" def test_valid_subpath_allowed(self, tmp_path: Path) -> None: @@ -415,7 +437,9 @@ def test_valid_subpath_allowed(self, tmp_path: Path) -> None: full_path = (Path(str(base_dir)) / "css/style.css").resolve() dir_path = Path(str(base_dir)).resolve() - within = str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path + within = ( + str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path + ) assert within is True def test_traversal_encoded_dots_blocked(self, tmp_path: Path) -> None: @@ -427,7 +451,9 @@ def test_traversal_encoded_dots_blocked(self, tmp_path: Path) -> None: full_path = (Path(str(base_dir)) / file_path).resolve() dir_path = Path(str(base_dir)).resolve() - within = str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path + within = ( + str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path + ) assert within is False def test_exact_base_dir_allowed(self, tmp_path: Path) -> None: @@ -438,7 +464,9 @@ def test_exact_base_dir_allowed(self, tmp_path: Path) -> None: full_path = Path(str(base_dir)).resolve() dir_path = Path(str(base_dir)).resolve() - within = str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path + within = ( + str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path + ) assert within is True def test_traversal_with_trailing_slash(self, tmp_path: Path) -> None: @@ -449,7 +477,9 @@ def test_traversal_with_trailing_slash(self, tmp_path: Path) -> None: full_path = (Path(str(base_dir)) / file_path).resolve() dir_path = Path(str(base_dir)).resolve() - within = str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path + within = ( + str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path + ) # "../" resolves to parent, which equals tmp_path, not base_dir # Unless base_dir IS tmp_path. Here base_dir is tmp_path/www so parent != www assert within is False @@ -466,7 +496,9 @@ def test_sibling_directory_blocked(self, tmp_path: Path) -> None: full_path = (Path(str(base_dir)) / file_path).resolve() dir_path = Path(str(base_dir)).resolve() - within = str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path + within = ( + str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path + ) assert within is False def test_null_byte_in_path(self, tmp_path: Path) -> None: @@ -481,14 +513,17 @@ def test_null_byte_in_path(self, tmp_path: Path) -> None: # --------------------------------------------------------------------------- -# XSS prevention – _generate_directory_listing +# XSS prevention - _generate_directory_listing # --------------------------------------------------------------------------- + class TestXSSPrevention: """Verify that HTML-special characters in file/directory names are escaped.""" - def test_script_in_filename_escaped(self, web_service_with_browsing: Any, tmp_path: Path) -> None: - malicious = tmp_path / '.txt' + def test_script_in_filename_escaped( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: + malicious = tmp_path / ".txt" try: malicious.write_text("xss") except (OSError, ValueError): @@ -498,25 +533,31 @@ def test_script_in_filename_escaped(self, web_service_with_browsing: Any, tmp_pa assert "", quote=True) in html - def test_html_in_directory_name_escaped(self, web_service_with_browsing: Any, tmp_path: Path) -> None: - malicious_dir = tmp_path / '' + def test_html_in_directory_name_escaped( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: + malicious_dir = tmp_path / "" try: malicious_dir.mkdir() except (OSError, ValueError): pytest.skip("OS does not allow special chars in directory names") html = web_service_with_browsing._generate_directory_listing(tmp_path, "/files") - raw_name = '' + raw_name = "" assert raw_name not in html assert escape(raw_name, quote=True) in html - def test_url_path_in_title_escaped(self, web_service_with_browsing: Any, tmp_path: Path) -> None: + def test_url_path_in_title_escaped( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: """The url_path used in the / <h1> must be escaped.""" xss_path = '/<script>alert("xss")</script>' html = web_service_with_browsing._generate_directory_listing(tmp_path, xss_path) assert '<script>alert("xss")</script>' not in html - def test_ampersand_in_filename_escaped(self, web_service_with_browsing: Any, tmp_path: Path) -> None: + def test_ampersand_in_filename_escaped( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: f = tmp_path / "a&b.txt" try: f.write_text("data") @@ -526,7 +567,9 @@ def test_ampersand_in_filename_escaped(self, web_service_with_browsing: Any, tmp # The raw '&' in a non-entity context should be escaped to '&' assert "a&b.txt" in html - def test_quote_in_filename_escaped(self, web_service_with_browsing: Any, tmp_path: Path) -> None: + def test_quote_in_filename_escaped( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: f = tmp_path / 'file"name.txt' try: f.write_text("data") @@ -538,40 +581,53 @@ def test_quote_in_filename_escaped(self, web_service_with_browsing: Any, tmp_pat # --------------------------------------------------------------------------- -# _generate_directory_listing – structural tests +# _generate_directory_listing - structural tests # --------------------------------------------------------------------------- + class TestDirectoryListing: """Non-security structural tests for _generate_directory_listing.""" - def test_root_path_no_parent_link(self, web_service_with_browsing: Any, tmp_path: Path) -> None: + def test_root_path_no_parent_link( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: html = web_service_with_browsing._generate_directory_listing(tmp_path, "/") assert "../" not in html - def test_non_root_path_has_parent_link(self, web_service_with_browsing: Any, tmp_path: Path) -> None: + def test_non_root_path_has_parent_link( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: html = web_service_with_browsing._generate_directory_listing(tmp_path, "/sub") assert "../" in html - def test_hidden_files_skipped(self, web_service_with_browsing: Any, tmp_path: Path) -> None: + def test_hidden_files_skipped( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: (tmp_path / ".hidden").write_text("hidden") (tmp_path / "visible.txt").write_text("visible") html = web_service_with_browsing._generate_directory_listing(tmp_path, "/files") assert ".hidden" not in html assert "visible.txt" in html - def test_file_size_bytes(self, web_service_with_browsing: Any, tmp_path: Path) -> None: + def test_file_size_bytes( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: f = tmp_path / "tiny.txt" f.write_text("ab") # 2 bytes html = web_service_with_browsing._generate_directory_listing(tmp_path, "/files") assert "B" in html - def test_file_size_kilobytes(self, web_service_with_browsing: Any, tmp_path: Path) -> None: + def test_file_size_kilobytes( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: f = tmp_path / "medium.txt" f.write_text("x" * 2048) html = web_service_with_browsing._generate_directory_listing(tmp_path, "/files") assert "KB" in html - def test_file_size_megabytes(self, web_service_with_browsing: Any, tmp_path: Path) -> None: + def test_file_size_megabytes( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: ws = _make_web_service( enable_directory_browsing=True, max_file_size=200 * 1024 * 1024, @@ -582,12 +638,16 @@ def test_file_size_megabytes(self, web_service_with_browsing: Any, tmp_path: Pat assert "MB" in html _stop_patches(ws) - def test_directories_listed(self, web_service_with_browsing: Any, tmp_path: Path) -> None: + def test_directories_listed( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: (tmp_path / "subdir").mkdir() html = web_service_with_browsing._generate_directory_listing(tmp_path, "/files") assert "subdir/" in html - def test_blocked_files_not_listed(self, web_service_with_browsing: Any, tmp_path: Path) -> None: + def test_blocked_files_not_listed( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: """Files that fail _is_file_allowed should not appear in listings.""" (tmp_path / "server.pem").write_text("cert") html = web_service_with_browsing._generate_directory_listing(tmp_path, "/files") @@ -595,9 +655,10 @@ def test_blocked_files_not_listed(self, web_service_with_browsing: Any, tmp_path # --------------------------------------------------------------------------- -# _get_current_username – basic auth validation +# _get_current_username - basic auth validation # --------------------------------------------------------------------------- + class TestGetCurrentUsername: """Tests for _get_current_username.""" @@ -657,6 +718,7 @@ def test_both_wrong_raises(self) -> None: # add_directory / remove_directory # --------------------------------------------------------------------------- + class TestAddRemoveDirectory: """Tests for add_directory and remove_directory helpers.""" @@ -677,7 +739,9 @@ def test_add_nonexistent_directory_raises(self, web_service: Any) -> None: with pytest.raises(ValueError, match="does not exist"): web_service.add_directory("/nope", "/nonexistent/path/xyz") - def test_add_file_as_directory_raises(self, web_service: Any, tmp_path: Path) -> None: + def test_add_file_as_directory_raises( + self, web_service: Any, tmp_path: Path + ) -> None: f = tmp_path / "file.txt" f.write_text("hi") with pytest.raises(ValueError, match="not a directory"): @@ -691,7 +755,9 @@ def test_remove_directory(self, web_service: Any, tmp_path: Path) -> None: web_service.remove_directory("/removeme") assert "/removeme" not in web_service.directories - def test_remove_directory_auto_slash(self, web_service: Any, tmp_path: Path) -> None: + def test_remove_directory_auto_slash( + self, web_service: Any, tmp_path: Path + ) -> None: d = tmp_path / "gone" d.mkdir() web_service.add_directory("/gone", str(d)) @@ -714,6 +780,7 @@ def test_remove_nonexistent_is_noop(self, web_service: Any) -> None: # _mount_directories # --------------------------------------------------------------------------- + class TestMountDirectories: """Tests for _mount_directories edge cases.""" @@ -734,7 +801,7 @@ def test_route_gets_leading_slash(self, web_service: Any, tmp_path: Path) -> Non d = tmp_path / "web" d.mkdir() web_service.directories = {"noslash": str(d)} - # _mount_directories normalises the route – it should not crash + # _mount_directories normalises the route - it should not crash web_service._mount_directories() @@ -742,6 +809,7 @@ def test_route_gets_leading_slash(self, web_service: Any, tmp_path: Path) -> Non # start / stop # --------------------------------------------------------------------------- + class TestStartStop: """Tests for the start and stop lifecycle methods.""" @@ -778,12 +846,14 @@ def test_start_with_ssl_params(self, web_service: Any) -> None: def test_start_without_uvicorn_raises(self, web_service: Any) -> None: web_service._basic_auth = ("u", "p") - with patch.dict("sys.modules", {"uvicorn": None}): - with pytest.raises((RuntimeError, ImportError)): - web_service.start() + with ( + patch.dict("sys.modules", {"uvicorn": None}), + pytest.raises((RuntimeError, ImportError)), + ): + web_service.start() def test_stop_is_noop(self, web_service: Any) -> None: - # stop() is a placeholder – should not raise + # stop() is a placeholder - should not raise web_service.stop() @@ -791,6 +861,7 @@ def test_stop_is_noop(self, web_service: Any) -> None: # _setup_security # --------------------------------------------------------------------------- + class TestSetupSecurity: """Tests for the _setup_security method.""" @@ -830,6 +901,7 @@ def test_cors_not_added_when_disabled(self) -> None: # _setup_routes # --------------------------------------------------------------------------- + class TestSetupRoutes: """Tests for the _setup_routes method.""" @@ -841,9 +913,7 @@ def test_no_app_returns_early(self) -> None: def test_routes_registered(self, web_service: Any) -> None: """After init, routes should exist on the app.""" if hasattr(web_service.app, "routes"): - route_paths = [ - getattr(r, "path", None) for r in web_service.app.routes - ] + route_paths = [getattr(r, "path", None) for r in web_service.app.routes] assert "/health" in route_paths assert "/" in route_paths @@ -852,19 +922,23 @@ def test_routes_registered(self, web_service: Any) -> None: # File extension / MIME type handling # --------------------------------------------------------------------------- + class TestMimeTypes: """Test that custom MIME types are registered during init.""" def test_js_mime_type(self, web_service: Any) -> None: import mimetypes as mt + assert mt.guess_type("script.js")[0] == "application/javascript" def test_css_mime_type(self, web_service: Any) -> None: import mimetypes as mt + assert mt.guess_type("style.css")[0] == "text/css" def test_json_mime_type(self, web_service: Any) -> None: import mimetypes as mt + assert mt.guess_type("data.json")[0] == "application/json" @@ -872,6 +946,7 @@ def test_json_mime_type(self, web_service: Any) -> None: # Edge-case: blocked extension detection nuances # --------------------------------------------------------------------------- + class TestBlockedExtensionEdgeCases: """Fine-grained tests for extension/name-based blocking logic.""" @@ -913,6 +988,7 @@ def test_unblocked_extension_allowed(self, tmp_path: Path) -> None: # Integration-style: directory listing excludes blocked & hidden # --------------------------------------------------------------------------- + class TestDirectoryListingFiltering: """Verify the listing respects both hidden-file and extension filters.""" @@ -936,10 +1012,13 @@ def test_combined_filtering(self, tmp_path: Path) -> None: # Regression: ensure stat errors don't crash directory listing # --------------------------------------------------------------------------- + class TestDirectoryListingStatErrors: """Ensure errors during directory iteration don't crash the listing.""" - def test_stat_error_on_file_skips_gracefully(self, web_service_with_browsing: Any, tmp_path: Path) -> None: + def test_stat_error_on_file_skips_gracefully( + self, web_service_with_browsing: Any, tmp_path: Path + ) -> None: """If _is_file_allowed returns False (e.g. stat error), the file should simply be omitted from the listing.""" f = tmp_path / "broken.txt" @@ -955,20 +1034,23 @@ def test_stat_error_on_file_skips_gracefully(self, web_service_with_browsing: An # NEW TESTS: _load_config branch coverage # --------------------------------------------------------------------------- + class TestLoadConfigBranches: """Tests for _load_config to cover missing branches (lines 124, 129).""" def test_load_config_find_returns_none(self) -> None: """When find_config_file returns None and no config_file given, _load_config should return early (line 124).""" - with patch( - "signalwire.web.web_service.SecurityConfig", - return_value=_make_security_mock(), - ), patch( - "signalwire.web.web_service.ConfigLoader" - ) as mock_cl_cls: + with ( + patch( + "signalwire.web.web_service.SecurityConfig", + return_value=_make_security_mock(), + ), + patch("signalwire.web.web_service.ConfigLoader") as mock_cl_cls, + ): mock_cl_cls.find_config_file.return_value = None from signalwire.web.web_service import WebService + ws = WebService(port=9999, directories={}) # Defaults should be set; ConfigLoader should not have been instantiated # for loading (only find_config_file was called) @@ -980,16 +1062,20 @@ def test_load_config_has_config_false(self) -> None: mock_loader = MagicMock() mock_loader.has_config.return_value = False - with patch( - "signalwire.web.web_service.SecurityConfig", - return_value=_make_security_mock(), - ), patch( - "signalwire.web.web_service.ConfigLoader", - return_value=mock_loader, - ) as mock_cl_cls: + with ( + patch( + "signalwire.web.web_service.SecurityConfig", + return_value=_make_security_mock(), + ), + patch( + "signalwire.web.web_service.ConfigLoader", + return_value=mock_loader, + ) as mock_cl_cls, + ): mock_cl_cls.find_config_file.return_value = "/fake/config.yaml" from signalwire.web.web_service import WebService - ws = WebService(port=9999, directories={}) + + WebService(port=9999, directories={}) # get_section should never be called mock_loader.get_section.assert_not_called() @@ -999,15 +1085,19 @@ def test_load_config_service_section_none(self) -> None: mock_loader.has_config.return_value = True mock_loader.get_section.return_value = None - with patch( - "signalwire.web.web_service.SecurityConfig", - return_value=_make_security_mock(), - ), patch( - "signalwire.web.web_service.ConfigLoader", - return_value=mock_loader, - ) as mock_cl_cls: + with ( + patch( + "signalwire.web.web_service.SecurityConfig", + return_value=_make_security_mock(), + ), + patch( + "signalwire.web.web_service.ConfigLoader", + return_value=mock_loader, + ) as mock_cl_cls, + ): mock_cl_cls.find_config_file.return_value = "/fake/config.yaml" from signalwire.web.web_service import WebService + ws = WebService(port=9999, directories={}) assert ws.directories == {} @@ -1019,15 +1109,19 @@ def test_load_config_directories_not_dict_ignored(self) -> None: "directories": "not-a-dict", # should be ignored } - with patch( - "signalwire.web.web_service.SecurityConfig", - return_value=_make_security_mock(), - ), patch( - "signalwire.web.web_service.ConfigLoader", - return_value=mock_loader, - ) as mock_cl_cls: + with ( + patch( + "signalwire.web.web_service.SecurityConfig", + return_value=_make_security_mock(), + ), + patch( + "signalwire.web.web_service.ConfigLoader", + return_value=mock_loader, + ) as mock_cl_cls, + ): mock_cl_cls.find_config_file.return_value = "/fake/config.yaml" from signalwire.web.web_service import WebService + ws = WebService(port=9999) # directories should remain the default empty dict since the non-dict # value was ignored by _load_config and no directories kwarg was given @@ -1038,29 +1132,38 @@ def test_load_config_directories_not_dict_ignored(self) -> None: # NEW TESTS: Route handler integration tests via TestClient # --------------------------------------------------------------------------- + class TestRouteHandlers: """Integration tests for the FastAPI route handlers using TestClient. Covers lines 313, 324-357 (root and health endpoints).""" - def _make_testable_service(self, directories: dict[str, str] | None = None, - enable_directory_browsing: bool = False, - basic_auth: tuple[str, str] | None = None, - max_file_size: int = 100 * 1024 * 1024, - blocked_extensions: list[str] | None = None, - allowed_extensions: list[str] | None = None) -> Any: + def _make_testable_service( + self, + directories: dict[str, str] | None = None, + enable_directory_browsing: bool = False, + basic_auth: tuple[str, str] | None = None, + max_file_size: int = 100 * 1024 * 1024, + blocked_extensions: list[str] | None = None, + allowed_extensions: list[str] | None = None, + ) -> Any: """Build a WebService with real FastAPI app for TestClient use.""" security_mock = _make_security_mock() - with patch( - "signalwire.web.web_service.SecurityConfig", - return_value=security_mock, - ), patch( - "signalwire.web.web_service.ConfigLoader.find_config_file", - return_value=None, - ), patch( - "signalwire.web.web_service.ConfigLoader", + with ( + patch( + "signalwire.web.web_service.SecurityConfig", + return_value=security_mock, + ), + patch( + "signalwire.web.web_service.ConfigLoader.find_config_file", + return_value=None, + ), + patch( + "signalwire.web.web_service.ConfigLoader", + ), ): from signalwire.web.web_service import WebService + ws = WebService( port=9999, directories=directories or {}, @@ -1076,6 +1179,7 @@ def _make_testable_service(self, directories: dict[str, str] | None = None, def test_health_endpoint(self) -> None: """GET /health should return status and configuration info.""" from starlette.testclient import TestClient + ws = self._make_testable_service() client = TestClient(ws.app) resp = client.get("/health") @@ -1085,10 +1189,11 @@ def test_health_endpoint(self) -> None: assert "directories" in data assert "directory_browsing" in data - def test_root_endpoint_html(self) -> None: + def test_root_endpoint_html(self, tmp_path: Path) -> None: """GET / should return HTML listing available directories.""" from starlette.testclient import TestClient - ws = self._make_testable_service(directories={"/docs": "/tmp"}) + + ws = self._make_testable_service(directories={"/docs": str(tmp_path)}) client = TestClient(ws.app) resp = client.get("/") assert resp.status_code == 200 @@ -1098,6 +1203,7 @@ def test_root_endpoint_html(self) -> None: def test_root_endpoint_no_directories(self) -> None: """GET / with no directories should still return valid HTML.""" from starlette.testclient import TestClient + ws = self._make_testable_service() client = TestClient(ws.app) resp = client.get("/") @@ -1111,6 +1217,7 @@ def _auth(self) -> tuple[str, str]: def test_serve_file_success(self, tmp_path: Path) -> None: """Serving a valid file should return its contents.""" from starlette.testclient import TestClient + d = tmp_path / "www" d.mkdir() (d / "hello.txt").write_text("hello world") @@ -1124,6 +1231,7 @@ def test_serve_file_success(self, tmp_path: Path) -> None: def test_serve_file_not_found(self, tmp_path: Path) -> None: """Requesting a nonexistent file should return 404.""" from starlette.testclient import TestClient + d = tmp_path / "www" d.mkdir() @@ -1135,6 +1243,7 @@ def test_serve_file_not_found(self, tmp_path: Path) -> None: def test_serve_file_path_traversal_denied(self, tmp_path: Path) -> None: """Path traversal attempts should return 403.""" from starlette.testclient import TestClient + d = tmp_path / "www" d.mkdir() # Create a file outside the served dir @@ -1150,6 +1259,7 @@ def test_serve_file_path_traversal_denied(self, tmp_path: Path) -> None: def test_serve_file_blocked_extension(self, tmp_path: Path) -> None: """Files with blocked extensions should return 403.""" from starlette.testclient import TestClient + d = tmp_path / "www" d.mkdir() (d / "secrets.env").write_text("SECRET=x") @@ -1162,6 +1272,7 @@ def test_serve_file_blocked_extension(self, tmp_path: Path) -> None: def test_serve_directory_browsing_disabled(self, tmp_path: Path) -> None: """When browsing is disabled and no index.html, return 403.""" from starlette.testclient import TestClient + d = tmp_path / "www" sub = d / "subdir" sub.mkdir(parents=True) @@ -1177,6 +1288,7 @@ def test_serve_directory_browsing_disabled(self, tmp_path: Path) -> None: def test_serve_directory_index_html_fallback(self, tmp_path: Path) -> None: """When browsing is disabled but index.html exists, serve it.""" from starlette.testclient import TestClient + d = tmp_path / "www" sub = d / "subdir" sub.mkdir(parents=True) @@ -1194,6 +1306,7 @@ def test_serve_directory_index_html_fallback(self, tmp_path: Path) -> None: def test_serve_directory_browsing_enabled(self, tmp_path: Path) -> None: """When directory browsing is enabled, return directory listing.""" from starlette.testclient import TestClient + d = tmp_path / "www" sub = d / "subdir" sub.mkdir(parents=True) @@ -1212,6 +1325,7 @@ def test_serve_directory_browsing_enabled(self, tmp_path: Path) -> None: def test_serve_file_mime_type_json(self, tmp_path: Path) -> None: """JSON files should be served with the correct MIME type.""" from starlette.testclient import TestClient + d = tmp_path / "www" d.mkdir() (d / "data.json").write_text('{"key": "value"}') @@ -1225,6 +1339,7 @@ def test_serve_file_mime_type_json(self, tmp_path: Path) -> None: def test_serve_file_cache_headers(self, tmp_path: Path) -> None: """Served files should include Cache-Control and X-Content-Type-Options.""" from starlette.testclient import TestClient + d = tmp_path / "www" d.mkdir() (d / "style.css").write_text("body {}") @@ -1238,6 +1353,7 @@ def test_serve_file_cache_headers(self, tmp_path: Path) -> None: def test_serve_file_too_large(self, tmp_path: Path) -> None: """Files exceeding max_file_size should be denied.""" from starlette.testclient import TestClient + d = tmp_path / "www" d.mkdir() big = d / "big.bin" @@ -1254,6 +1370,7 @@ def test_serve_file_too_large(self, tmp_path: Path) -> None: def test_serve_file_allowed_extension_filter(self, tmp_path: Path) -> None: """When allowed_extensions is set, only those should be served.""" from starlette.testclient import TestClient + d = tmp_path / "www" d.mkdir() (d / "page.html").write_text("<p>hi</p>") @@ -1272,6 +1389,7 @@ def test_serve_file_allowed_extension_filter(self, tmp_path: Path) -> None: def test_serve_file_wrong_auth_rejected(self, tmp_path: Path) -> None: """Requests with wrong credentials should be rejected.""" from starlette.testclient import TestClient + d = tmp_path / "www" d.mkdir() (d / "hello.txt").write_text("hello") @@ -1286,27 +1404,35 @@ def test_serve_file_wrong_auth_rejected(self, tmp_path: Path) -> None: # NEW TESTS: Security middleware coverage # --------------------------------------------------------------------------- + class TestSecurityMiddleware: """Tests for security middleware (lines 169-182, 187-191). Uses TestClient to exercise the middleware in-process.""" def _make_testable_service(self, **kwargs: Any) -> Any: security_mock = _make_security_mock() - with patch( - "signalwire.web.web_service.SecurityConfig", - return_value=security_mock, - ), patch( - "signalwire.web.web_service.ConfigLoader.find_config_file", - return_value=None, - ), patch( - "signalwire.web.web_service.ConfigLoader", + with ( + patch( + "signalwire.web.web_service.SecurityConfig", + return_value=security_mock, + ), + patch( + "signalwire.web.web_service.ConfigLoader.find_config_file", + return_value=None, + ), + patch( + "signalwire.web.web_service.ConfigLoader", + ), ): from signalwire.web.web_service import WebService + ws = WebService( port=9999, directories=kwargs.get("directories", {}), basic_auth=("testuser", "testpass"), - enable_directory_browsing=kwargs.get("enable_directory_browsing", False), + enable_directory_browsing=kwargs.get( + "enable_directory_browsing", False + ), ) ws._test_security_mock = security_mock # type: ignore[attr-defined] # test-only return ws @@ -1314,6 +1440,7 @@ def _make_testable_service(self, **kwargs: Any) -> Any: def test_security_headers_added_to_response(self) -> None: """Security headers from SecurityConfig should be added to responses.""" from starlette.testclient import TestClient + ws = self._make_testable_service() client = TestClient(ws.app) resp = client.get("/health") @@ -1325,6 +1452,7 @@ def test_security_headers_added_to_response(self) -> None: def test_host_validation_blocks_invalid_host(self) -> None: """When should_allow_host returns False, the request should be rejected.""" from starlette.testclient import TestClient + ws = self._make_testable_service() # Make should_allow_host return False for invalid hosts ws._test_security_mock.should_allow_host.return_value = False @@ -1336,6 +1464,7 @@ def test_host_validation_blocks_invalid_host(self) -> None: def test_host_validation_allows_valid_host(self) -> None: """When should_allow_host returns True, the request should proceed.""" from starlette.testclient import TestClient + ws = self._make_testable_service() ws._test_security_mock.should_allow_host.return_value = True client = TestClient(ws.app) @@ -1345,6 +1474,7 @@ def test_host_validation_allows_valid_host(self) -> None: def test_cache_headers_for_static_directory_paths(self, tmp_path: Path) -> None: """Requests to configured directory paths should get cache headers.""" from starlette.testclient import TestClient + d = tmp_path / "static" d.mkdir() (d / "app.js").write_text("console.log('hi')") @@ -1361,13 +1491,14 @@ def test_cache_headers_for_static_directory_paths(self, tmp_path: Path) -> None: # NEW TESTS: _mount_directories edge cases # --------------------------------------------------------------------------- + class TestMountDirectoriesEdgeCases: """Additional edge cases for _mount_directories (line 362).""" - def test_mount_no_app_returns_early(self) -> None: + def test_mount_no_app_returns_early(self, tmp_path: Path) -> None: """When self.app is None, _mount_directories should return immediately.""" ws = _make_web_service(fastapi_available=False) - ws.directories = {"/test": "/tmp"} + ws.directories = {"/test": str(tmp_path)} ws._mount_directories() # should not raise _stop_patches(ws) @@ -1386,6 +1517,7 @@ def test_mount_with_valid_directory(self, tmp_path: Path) -> None: # NEW TESTS: start() method edge cases # --------------------------------------------------------------------------- + class TestStartEdgeCases: """Additional tests for start() covering SSL config paths.""" @@ -1418,7 +1550,9 @@ def test_start_prints_ssl_enabled(self, capsys: pytest.CaptureFixture[str]) -> N assert "SSL: Enabled" in captured.out _stop_patches(ws) - def test_start_prints_directory_none_when_empty(self, capsys: pytest.CaptureFixture[str]) -> None: + def test_start_prints_directory_none_when_empty( + self, capsys: pytest.CaptureFixture[str] + ) -> None: """When no directories configured, should print 'None'.""" ws = _make_web_service(basic_auth=("u", "p")) ws.directories = {} @@ -1429,7 +1563,9 @@ def test_start_prints_directory_none_when_empty(self, capsys: pytest.CaptureFixt assert "None" in captured.out _stop_patches(ws) - def test_start_https_scheme_in_output(self, capsys: pytest.CaptureFixture[str]) -> None: + def test_start_https_scheme_in_output( + self, capsys: pytest.CaptureFixture[str] + ) -> None: """When SSL params are given, scheme should be https.""" ws = _make_web_service(basic_auth=("u", "p")) mock_uvicorn = MagicMock() @@ -1439,7 +1575,9 @@ def test_start_https_scheme_in_output(self, capsys: pytest.CaptureFixture[str]) assert "https://" in captured.out _stop_patches(ws) - def test_start_http_scheme_in_output(self, capsys: pytest.CaptureFixture[str]) -> None: + def test_start_http_scheme_in_output( + self, capsys: pytest.CaptureFixture[str] + ) -> None: """When no SSL, scheme should be http.""" ws = _make_web_service(basic_auth=("u", "p")) mock_uvicorn = MagicMock() @@ -1454,6 +1592,7 @@ def test_start_http_scheme_in_output(self, capsys: pytest.CaptureFixture[str]) - # NEW TESTS: add_directory with app already running # --------------------------------------------------------------------------- + class TestAddDirectoryWithApp: """Tests for add_directory when app is already set."""