diff --git a/.github/workflows/optional-contracts.yml b/.github/workflows/optional-contracts.yml index f7e18f890..2a2809b6a 100644 --- a/.github/workflows/optional-contracts.yml +++ b/.github/workflows/optional-contracts.yml @@ -9,7 +9,6 @@ on: - 'pyproject.toml' - 'pytest.ini' - 'uv.lock' - - 'scripts/assert-required-tests.py' - '.github/workflows/optional-contracts.yml' push: branches: [master, develop] @@ -19,7 +18,6 @@ on: - 'pyproject.toml' - 'pytest.ini' - 'uv.lock' - - 'scripts/assert-required-tests.py' - '.github/workflows/optional-contracts.yml' workflow_dispatch: @@ -87,12 +85,8 @@ jobs: env: LANGBOT_CLI_BIN: ${{ runner.temp }}/langbot-cli/lbctl run: | - uv run --no-sync pytest \ - tests/unit_tests/api/service/test_cli_diagnostics.py::test_compiled_cli_against_core_http \ + uv run --no-sync pytest --required-suite compiled_cli \ -q --tb=short -rs --junitxml=compiled-cli-results.xml - python scripts/assert-required-tests.py compiled-cli-results.xml \ - --module tests.unit_tests.api.service.test_cli_diagnostics \ - --name-prefix test_compiled_cli_against_core_http --expected-tests 1 - name: Upload compiled CLI contract result if: always() @@ -151,12 +145,8 @@ jobs: env: LANGBOT_LEGACY_PLUGIN_ROOT: ${{ github.workspace }}/.contract-sources/langbot-plugins run: | - uv run --no-sync pytest \ - tests/unit_tests/agent/test_legacy_identity_boundary.py::test_real_plugin_identity_helper_with_host_payload \ + uv run --no-sync pytest --required-suite plugin_identity \ -q --tb=short -rs --junitxml=plugin-identity-results.xml - python scripts/assert-required-tests.py plugin-identity-results.xml \ - --module tests.unit_tests.agent.test_legacy_identity_boundary \ - --name-prefix test_real_plugin_identity_helper_with_host_payload --expected-tests 16 - name: Upload plugin identity results if: always() @@ -198,11 +188,9 @@ jobs: print(f"pyseekdb={version('pyseekdb')}, pylibseekdb={version('pylibseekdb')}") PY - uv run --no-sync pytest tests/integration/vector/test_seekdb.py \ - -m slow -q --tb=short -rs --durations=5 \ + uv run --no-sync pytest --required-suite seekdb \ + -q --tb=short -rs --durations=5 \ --basetemp="$SEEKDB_TEST_TMP" --junitxml=seekdb-contract-results.xml - python scripts/assert-required-tests.py seekdb-contract-results.xml \ - --module tests.integration.vector.test_seekdb --name-prefix test_ --expected-tests 2 - name: Upload SeekDB results and startup logs if: always() diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 1912f9c6e..bd54e498f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -11,7 +11,7 @@ on: - 'uv.lock' - 'run_tests.sh' - 'scripts/test-*.sh' - - 'scripts/assert-required-tests.py' + - 'pytest.ini' push: branches: - master @@ -50,11 +50,8 @@ jobs: - name: Run required security regressions run: | - uv run pytest tests/test_cwe94_debug_exec.py \ + uv run pytest --required-suite security \ -q --tb=short -rs --junitxml=cwe94-results.xml - python scripts/assert-required-tests.py cwe94-results.xml \ - --module tests.test_cwe94_debug_exec \ - --name-prefix test_no_ --expected-tests 2 - name: Upload security regression results if: always() @@ -149,36 +146,8 @@ jobs: - name: Run required PostgreSQL contracts run: | - uv run pytest \ - tests/integration/persistence/test_pipeline_admission_postgres.py \ - tests/integration/persistence/test_rag_document_identity.py \ - tests/integration/persistence/test_rag_interrupted_ingestion.py \ - tests/integration/persistence/test_migration_branch_convergence.py \ - tests/integration/persistence/test_runner_timestamps_postgres.py \ - -k postgres -q --tb=short -rs --durations=10 \ - --junitxml=postgres-contract-results.xml - python - <<'PY' - from collections import Counter - import xml.etree.ElementTree as ET - - cases = list(ET.parse('postgres-contract-results.xml').getroot().iter('testcase')) - expected = { - 'test_pipeline_admission_postgres': 27, - 'test_rag_document_identity': 34, - 'test_rag_interrupted_ingestion': 22, - 'test_migration_branch_convergence': 7, - 'test_runner_timestamps_postgres': 32, - } - counts = Counter(case.get('classname', '').rsplit('.', 1)[-1] for case in cases) - for module, minimum in expected.items(): - assert counts[module] >= minimum, f'{module}: expected at least {minimum} cases, got {counts[module]}' - unsuccessful = [ - case.get('name') for case in cases - if any(case.find(tag) is not None for tag in ('skipped', 'failure', 'error')) - ] - assert not unsuccessful, f'PostgreSQL contracts must pass without skips: {unsuccessful}' - print(f'PostgreSQL contracts: {len(cases)} passed, 0 skipped; {dict(counts)}') - PY + uv run pytest --required-suite postgres \ + -q --tb=short -rs --durations=10 --junitxml=postgres-contract-results.xml - name: Upload PostgreSQL contract results if: always() @@ -196,6 +165,30 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Read official plugin source pin + id: plugin-source + run: | + python3 - <<'PY' + import json + import os + import re + from pathlib import Path + + lock = json.loads(Path('tests/fixtures/legacy_plugin_identity_sources.json').read_text()) + assert lock['repository'] == 'langbot-app/langbot-plugins' + assert re.fullmatch(r'[0-9a-f]{40}', lock['revision']) + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f"revision={lock['revision']}\n") + PY + + - name: Checkout pinned official plugin sources + uses: actions/checkout@v4 + with: + repository: langbot-app/langbot-plugins + ref: ${{ steps.plugin-source.outputs.revision }} + path: .contract-sources/langbot-plugins + persist-credentials: false + - name: Set up Python uses: actions/setup-python@v5 with: @@ -208,7 +201,25 @@ jobs: run: uv sync --dev - name: Run E2E startup tests - run: uv run pytest tests/e2e -q --tb=short + run: uv run pytest tests/e2e -m "not local_agent" -q --tb=short + + - name: Check Docker for the required LocalAgent Box contract + run: docker info + + - name: Run required LocalAgent contracts + env: + LANGBOT_LEGACY_PLUGIN_ROOT: ${{ github.workspace }}/.contract-sources/langbot-plugins + run: | + uv run --no-sync pytest --required-suite local_agent \ + -q --tb=short -rs --junitxml=local-agent-results.xml + + - name: Upload LocalAgent contract results + if: always() + uses: actions/upload-artifact@v4 + with: + name: local-agent-contract-results + path: local-agent-results.xml + if-no-files-found: ignore - name: E2E Test Summary if: always() diff --git a/docs/DEPENDENCY_CONTRACT_TESTS.md b/docs/DEPENDENCY_CONTRACT_TESTS.md index 48168d529..6c038c99f 100644 --- a/docs/DEPENDENCY_CONTRACT_TESTS.md +++ b/docs/DEPENDENCY_CONTRACT_TESTS.md @@ -47,12 +47,12 @@ To run with an official checkout at the locked revision: ```bash export LANGBOT_LEGACY_PLUGIN_ROOT=/absolute/path/to/langbot-plugins -uv run --frozen pytest tests/unit_tests/agent/test_legacy_identity_boundary.py -q -rs +uv run --frozen pytest --required-suite plugin_identity -q -rs ``` An unset variable allows an explicit local prerequisite skip. An empty variable, a missing file, a path escaping the fixture root, or a hash mismatch fails. -CI selects the 16 consumer cases and requires all 16 to pass without skips. +CI selects the `plugin_identity` suite and requires every selected case to pass without skips. ## Embedded SeekDB @@ -64,10 +64,8 @@ The tests supply their own small embedding vectors; no model download is needed. ```bash uv sync --dev --extra seekdb --frozen -uv run --no-sync pytest tests/integration/vector/test_seekdb.py \ - -m slow -q -rs --durations=5 --junitxml=seekdb-contract-results.xml -python scripts/assert-required-tests.py seekdb-contract-results.xml \ - --module tests.integration.vector.test_seekdb --expected-tests 2 +uv run --no-sync pytest --required-suite seekdb \ + -q -rs --durations=5 --junitxml=seekdb-contract-results.xml ``` This isolated Linux job keeps native dependency startup failures visible instead @@ -87,7 +85,7 @@ CI builds the official [`langbot-app/langbot-cli` v0.2.0 source](https://github. pinned to commit `3bd20eecd4771b1fe5d0dca61d8b5523182e7bd9`, using Go 1.26.8. Module versions and checksums come from that checkout's `go.mod` and `go.sum`. The job verifies modules and builds with `CGO_ENABLED=0`, `-mod=readonly`, and -`-trimpath`. It requires exactly one passing JUnit case and rejects skips. +`-trimpath`. It requires the CLI HTTP behavior contract and rejects skips. To run locally with an official pinned CLI checkout and Go 1.26.8: @@ -101,12 +99,35 @@ go build -mod=readonly -trimpath -o ./bin/lbctl ./cmd/lbctl export LANGBOT_CLI_BIN="$(pwd)/bin/lbctl" # In the LangBot Core checkout: -uv run --frozen pytest \ - tests/unit_tests/api/service/test_cli_diagnostics.py::test_compiled_cli_against_core_http \ - -q --tb=short -rs +uv run --frozen pytest --required-suite compiled_cli -q --tb=short -rs ``` Without `LANGBOT_CLI_BIN`, this test remains an explicit local skip. When set, it must point to a real compiled executable. CI supplies an absolute path and treats a missing binary, command failure, or skipped case as a failure. Update the source pin deliberately and rerun the contract before accepting a newer CLI version. + +## Required suite policy + +CI provisions dependencies and invokes `pytest --required-suite SUITE`. The +registered suites are `postgres`, `local_agent`, `security`, `compiled_cli`, +`plugin_identity`, and `seekdb`. Tests carry explicit suite markers; mixed +SQLite/PostgreSQL fixtures mark only their PostgreSQL parameter. New marked +cases are discovered automatically, without editing workflow file lists or counts. + +`tests/utils/required_suites.py` lists the stable behavior obligations for each +suite. `@pytest.mark.contract('name')` identifies the behavior a case exercises. +Only a new or retired behavior obligation changes this policy; renaming, moving, +or parameterizing an existing test does not. Unmarked sibling tests are not run +by a required suite. + +Required execution fails if a behavior obligation is absent, no cases are +selected, or any selected case fails, errors, skips, or xfails (including fixture +setup/teardown). Partial `-k`, `-m`, and `--deselect` filters are rejected. +`--collect-only` validates selection without claiming execution. Without +`--required-suite`, optional local prerequisites may still skip as before. +JUnit remains an artifact, not a second source of the expected test inventory. + +These checks prevent missing execution; they cannot prove that a behavior marker +is truthful or that an assertion detects defects. Review and mutation checks +still establish assertion quality. diff --git a/pytest.ini b/pytest.ini index d86cbba9f..8f543757a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -28,6 +28,13 @@ addopts = # Markers markers = + contract(name): stable behavior obligation for required suites + postgres: required postgres execution suite + local_agent: required local_agent execution suite + security: required security execution suite + compiled_cli: required compiled_cli execution suite + plugin_identity: required plugin_identity execution suite + seekdb: required seekdb execution suite asyncio: mark test as async unit: mark test as unit test integration: mark test as integration test diff --git a/scripts/assert-required-tests.py b/scripts/assert-required-tests.py deleted file mode 100644 index 2ff1f6ced..000000000 --- a/scripts/assert-required-tests.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Fail closed when a provisioned dependency-contract lane skips or loses tests.""" - -from __future__ import annotations - -import argparse -from pathlib import Path -import xml.etree.ElementTree as ET - - -def verify_report(path: Path, expected_module: str, expected_tests: int, name_prefix: str = '') -> int: - cases = list(ET.parse(path).getroot().iter('testcase')) - if len(cases) != expected_tests: - raise ValueError(f'Expected {expected_tests} tests, found {len(cases)} in {path}') - for case in cases: - if case.get('classname') != expected_module: - raise ValueError(f'Unexpected test module: {case.get("classname")!r}') - if not case.get('name', '').startswith(name_prefix): - raise ValueError(f'Unexpected test name: {case.get("name")!r}') - for outcome in ('skipped', 'failure', 'error'): - if case.find(outcome) is not None: - raise ValueError(f'Required test {case.get("name")!r} reported {outcome}') - return len(cases) - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('report', type=Path) - parser.add_argument('--module', required=True) - parser.add_argument('--expected-tests', type=int, required=True) - parser.add_argument('--name-prefix', default='') - args = parser.parse_args() - if args.expected_tests < 1: - parser.error('--expected-tests must be positive') - count = verify_report(args.report, args.module, args.expected_tests, args.name_prefix) - print(f'{args.module}: {count} required tests passed, 0 skipped') - - -if __name__ == '__main__': - main() diff --git a/src/langbot/libs/dify_service_api/v1/client_test.py b/src/langbot/libs/dify_service_api/v1/client_test.py deleted file mode 100644 index 2695b2ea4..000000000 --- a/src/langbot/libs/dify_service_api/v1/client_test.py +++ /dev/null @@ -1,17 +0,0 @@ -from . import client - -import asyncio - -import os - - -class TestDifyClient: - async def test_chat_messages(self): - cln = client.DifyClient(api_key=os.getenv('DIFY_API_KEY')) - - resp = await cln.chat_messages(inputs={}, query='Who are you?', user_id='test') - print(resp) - - -if __name__ == '__main__': - asyncio.run(TestDifyClient().test_chat_messages()) diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index ee2ee7dc9..4ad52c7d4 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -111,7 +111,11 @@ class UserRouterGroup(group.RouterGroup): async def _() -> str: if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud': return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud') - json_data = await quart.request.json + json_data = await quart.request.get_json(silent=True) + if not isinstance(json_data, dict) or not all( + isinstance(json_data.get(field), str) for field in ('user', 'password') + ): + return self.http_status(400, -1, 'Invalid request parameters') user_email = json_data['user'] password = json_data['password'] diff --git a/src/langbot/pkg/box/sandbox_files.py b/src/langbot/pkg/box/sandbox_files.py new file mode 100644 index 000000000..730260149 --- /dev/null +++ b/src/langbot/pkg/box/sandbox_files.py @@ -0,0 +1,139 @@ +"""Bounded, link-free outbox operations executed inside the bound sandbox.""" + +import os +import textwrap + + +def outbox_script( + outbox_root: str, + query_key: str, + *, + operation: str, + max_file_bytes: int, + max_files: int, + max_total_bytes: int, +) -> str: + settings = { + 'workspace': os.path.dirname(outbox_root), + 'outbox': os.path.basename(outbox_root), + 'query': query_key, + 'operation': operation, + 'max_file_bytes': max_file_bytes, + 'max_files': max_files, + 'max_total_bytes': max_total_bytes, + } + return f'settings = {settings!r}\n' + textwrap.dedent("""\ + import base64, errno, json, os, stat + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + file_flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK + out = [] + budget = {'entries': 512, 'directories': 64, 'bytes': 0} + + def component(value): + if (not value or value in {'.', '..'} or len(os.fsencode(value)) > 240 + or any(c in value for c in ('/', '\\\\', '\\x00'))): + raise RuntimeError('Unsafe sandbox outbox path component') + return value + + def read_directory(fd, prefix='', depth=0): + if depth > 8: + return + with os.scandir(fd) as entries: + for entry in entries: + if len(out) >= settings['max_files']: + return + if budget['entries'] <= 0: + raise RuntimeError('Sandbox outbox exceeds the directory-entry limit') + budget['entries'] -= 1 + name = component(entry.name) + relative = prefix + '/' + name if prefix else name + if entry.is_symlink(): + continue + if entry.is_dir(follow_symlinks=False): + if budget['directories'] <= 0: + raise RuntimeError('Sandbox outbox exceeds the directory limit') + budget['directories'] -= 1 + flags = directory_flags + elif entry.is_file(follow_symlinks=False): + flags = file_flags + else: + continue + try: + child_fd = os.open(name, flags, dir_fd=fd) + except OSError as exc: + if exc.errno in {errno.ENOENT, errno.ELOOP, errno.ENOTDIR}: + continue + raise + try: + metadata = os.fstat(child_fd) + if flags == directory_flags and stat.S_ISDIR(metadata.st_mode): + read_directory(child_fd, relative, depth + 1) + continue + if not stat.S_ISREG(metadata.st_mode): + continue + remaining = settings['max_total_bytes'] - budget['bytes'] + if metadata.st_size > settings['max_file_bytes'] or metadata.st_size > remaining: + raise RuntimeError('Sandbox output attachment exceeds the export size limit') + with os.fdopen(child_fd, 'rb', closefd=False) as source: + data = source.read(settings['max_file_bytes'] + 1) + if len(data) > settings['max_file_bytes'] or len(data) > remaining: + raise RuntimeError('Sandbox output attachment exceeds the export size limit') + out.append({'name': relative, 'b64': base64.b64encode(data).decode('ascii')}) + budget['bytes'] += len(data) + finally: + os.close(child_fd) + + def clear_directory(fd, depth=0): + if depth > 16: + raise RuntimeError('Sandbox cleanup exceeded its depth limit') + with os.scandir(fd) as entries: + for entry in entries: + if budget['entries'] <= 0: + raise RuntimeError('Sandbox cleanup exceeded its inode limit') + budget['entries'] -= 1 + name = component(entry.name) + try: + child_fd = os.open(name, directory_flags, dir_fd=fd) + except FileNotFoundError: + continue + except OSError as exc: + if exc.errno not in {errno.ELOOP, errno.ENOTDIR}: + raise + try: + os.unlink(name, dir_fd=fd) + except FileNotFoundError: + pass + else: + try: + clear_directory(child_fd, depth + 1) + finally: + os.close(child_fd) + try: + os.rmdir(name, dir_fd=fd) + except FileNotFoundError: + pass + + try: + workspace_fd = os.open(settings['workspace'], directory_flags) + try: + outbox_fd = os.open(component(settings['outbox']), directory_flags, dir_fd=workspace_fd) + try: + query_fd = os.open(component(settings['query']), directory_flags, dir_fd=outbox_fd) + try: + if settings['operation'] == 'read': + read_directory(query_fd) + elif settings['operation'] == 'clear': + budget['entries'] = 4096 + clear_directory(query_fd) + else: + raise RuntimeError('Unknown sandbox outbox operation') + finally: + os.close(query_fd) + finally: + os.close(outbox_fd) + finally: + os.close(workspace_fd) + except FileNotFoundError: + pass + print(json.dumps(out)) + """) diff --git a/src/langbot/pkg/box/secure_fs.py b/src/langbot/pkg/box/secure_fs.py index 4e6c7de3a..2a0d2166a 100644 --- a/src/langbot/pkg/box/secure_fs.py +++ b/src/langbot/pkg/box/secure_fs.py @@ -14,7 +14,9 @@ class UnsafeWorkspacePathError(OSError): _DIRECTORY_FLAGS = ( os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) ) -_FILE_READ_FLAGS = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) +_FILE_READ_FLAGS = ( + os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NONBLOCK', 0) +) _FILE_WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) _MAX_REMOVAL_ENTRIES = 4096 _MAX_REMOVAL_DEPTH = 16 @@ -45,6 +47,8 @@ def _unsafe(path: str, exc: BaseException | None = None) -> UnsafeWorkspacePathE def _root_fd(root: str): try: fd = os.open(root, _DIRECTORY_FLAGS) + except PermissionError: + raise except OSError as exc: raise _unsafe(root, exc) try: @@ -64,6 +68,8 @@ def _open_dir_at(parent_fd: int, name: str, *, create: bool) -> int: pass try: fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd) + except PermissionError: + raise except OSError as exc: raise _unsafe(name, exc) if not stat.S_ISDIR(os.fstat(fd).st_mode): @@ -91,6 +97,8 @@ def _remove_entry( child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd) except FileNotFoundError: return + except PermissionError: + raise except OSError as exc: if exc.errno not in {errno.ELOOP, errno.ENOTDIR, errno.EACCES}: raise @@ -198,6 +206,8 @@ def _read_directory( remaining_directories[0] -= 1 try: child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=directory_fd) + except PermissionError: + raise except OSError: continue try: @@ -218,6 +228,8 @@ def _read_directory( continue try: file_fd = os.open(name, _FILE_READ_FLAGS, dir_fd=directory_fd) + except PermissionError: + raise except OSError: continue try: diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index 4527b954d..c4c4552c1 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -1129,12 +1129,10 @@ class BoxService: async def collect_outbound_attachments(self, query: pipeline_query.Query) -> list[dict]: """Collect files the agent produced in the sandbox outbox. - Reads ``/workspace/outbox//`` (recursively) — directly from - the bind-mounted host directory when available (no size limit), else - via the exec channel — returns a list of ``{type, name, base64}`` - ready to become platform message components, then clears the outbox so - a later turn in the same session does not re-send stale files. Returns - ``[]`` when nothing was produced. + Read regular files within the configured attachment limits from the + current run's outbox. Private container output uses the bound sandbox + channel; invalid or truncated responses retain the files. Clear a + successful collection and return ``{type, name, base64}`` descriptors. """ if not self._available: return [] @@ -1143,7 +1141,11 @@ class BoxService: host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query) if host_dir is not None: - entries = await asyncio.to_thread(self._read_outbox_host, host_dir) + try: + entries = await asyncio.to_thread(self._read_outbox_host, host_dir) + except PermissionError: + # Root-owned container output can be private to its sandbox UID. + entries = await self._read_outbox_via_exec(query) else: entries = await self._read_outbox_via_exec(query) @@ -1187,57 +1189,17 @@ class BoxService: """ import json as _json - target_dir = f'{self.OUTBOX_MOUNT_DIR}/{self._attachment_query_key(query)}' - # Outbound bytes travel in raw stdout, not argv. Use the regular - # attachment limits rather than the smaller inbound command limit. + from .sandbox_files import outbox_script + max_file_bytes = self._ATTACHMENT_MAX_BYTES - max_files = self._ATTACHMENT_MAX_FILES max_total_bytes = self._ATTACHMENT_MAX_TOTAL_BYTES - max_scan_entries = 1000 - script = ( - 'import base64, json, os\n' - f'target = {target_dir!r}\n' - f'max_file_bytes = {max_file_bytes}\n' - f'max_files = {max_files}\n' - f'max_total_bytes = {max_total_bytes}\n' - f'max_scan_entries = {max_scan_entries}\n' - 'out = []\n' - 'total_bytes = 0\n' - 'scanned_entries = 0\n' - 'stack = [target]\n' - 'if os.path.isdir(target):\n' - ' while stack and len(out) < max_files and scanned_entries < max_scan_entries:\n' - ' current = stack.pop()\n' - ' try:\n' - ' with os.scandir(current) as iterator:\n' - ' entries = sorted(iterator, key=lambda item: item.name, reverse=True)\n' - ' except OSError:\n' - ' continue\n' - ' for entry in entries:\n' - ' scanned_entries += 1\n' - ' if scanned_entries > max_scan_entries:\n' - ' break\n' - ' try:\n' - ' if entry.is_dir(follow_symlinks=False):\n' - ' stack.append(entry.path)\n' - ' continue\n' - ' if not entry.is_file(follow_symlinks=False):\n' - ' continue\n' - ' size = entry.stat(follow_symlinks=False).st_size\n' - ' if size > max_file_bytes or total_bytes + size > max_total_bytes:\n' - " raise RuntimeError('Sandbox output attachment exceeds the export size limit')\n" - " with open(entry.path, 'rb') as f:\n" - ' data = f.read(max_file_bytes + 1)\n' - ' if len(data) > max_file_bytes or total_bytes + len(data) > max_total_bytes:\n' - " raise RuntimeError('Sandbox output attachment exceeds the export size limit')\n" - ' except OSError:\n' - ' continue\n' - ' rel = os.path.relpath(entry.path, target)\n' - " out.append({'name': rel, 'b64': base64.b64encode(data).decode('ascii')})\n" - ' total_bytes += len(data)\n' - ' if len(out) >= max_files:\n' - ' break\n' - 'print(json.dumps(out))\n' + script = outbox_script( + self.OUTBOX_MOUNT_DIR, + self._attachment_query_key(query), + operation='read', + max_file_bytes=max_file_bytes, + max_files=self._ATTACHMENT_MAX_FILES, + max_total_bytes=max_total_bytes, ) spec_payload: dict = { 'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY", @@ -1288,19 +1250,37 @@ class BoxService: try: secure_fs.reset_directory(tenant_root, subdir, query_key) return False + except secure_fs.UnsafeWorkspacePathError: + raise except OSError: return True - survived = await asyncio.to_thread(_clear) + try: + survived = await asyncio.to_thread(_clear) + except secure_fs.UnsafeWorkspacePathError as exc: + self.ap.logger.warning(f'Failed to clear unsafe sandbox outbox {target_dir}: {exc}') + return if not survived: return # Root-owned container files survived the host delete — fall through. try: - await self.execute_tool( - {'command': f'rm -rf {target_dir} && mkdir -p {target_dir}', 'timeout_sec': 30}, + from .sandbox_files import outbox_script + + script = outbox_script( + self.OUTBOX_MOUNT_DIR, + self._attachment_query_key(query), + operation='clear', + max_file_bytes=self._ATTACHMENT_MAX_BYTES, + max_files=self._ATTACHMENT_MAX_FILES, + max_total_bytes=self._ATTACHMENT_MAX_TOTAL_BYTES, + ) + result = await self.execute_tool( + {'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 30}, query, ) + if not result.get('ok'): + raise BoxValidationError('Sandbox outbox cleanup failed') except Exception as exc: self.ap.logger.warning(f'Failed to clear sandbox outbox {target_dir}: {exc}') diff --git a/src/langbot/pkg/utils/python_workspace.py b/src/langbot/pkg/utils/python_workspace.py index 20a23ffa2..96794eb9e 100644 --- a/src/langbot/pkg/utils/python_workspace.py +++ b/src/langbot/pkg/utils/python_workspace.py @@ -49,7 +49,7 @@ def wrap_python_command_with_env( writable_state_path = state_path or mount_path bootstrap = textwrap.dedent( - f""" + rf""" set -e _LB_VENV_DIR="{writable_state_path}/.venv" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..8e63694e3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +"""Repository-wide test execution policy.""" + +pytest_plugins = ['tests.utils.required_suites'] diff --git a/tests/e2e/test_local_runner_fake_provider.py b/tests/e2e/test_local_runner_fake_provider.py index 37a4b9044..cbb953bad 100644 --- a/tests/e2e/test_local_runner_fake_provider.py +++ b/tests/e2e/test_local_runner_fake_provider.py @@ -1,7 +1,7 @@ """E2E coverage for the official Local Agent runner with fake Host resources. These tests start the real LangBot application and the real SDK Plugin Runtime, -load the consolidated ``langbot-plugin-demo/Runner/LocalAgent`` plugin, and verify Local Agent paths +load the pinned official ``langbot-plugins/Runner/LocalAgent`` plugin, and verify Local Agent paths that must cross Host run-scoped APIs without calling any external provider. """ @@ -13,6 +13,7 @@ import os import shutil import socket import sqlite3 +import stat import subprocess import sys import tempfile @@ -23,9 +24,9 @@ from typing import Any import pytest from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories -from tests.e2e.utils.process_manager import find_project_root +from tests.utils.legacy_plugin_contract import verified_plugin_root -pytestmark = pytest.mark.e2e +pytestmark = [pytest.mark.e2e, pytest.mark.local_agent] LOCAL_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default' @@ -43,16 +44,15 @@ def _free_port() -> int: def _local_agent_repo() -> Path: - """Return the sibling local-agent repository used by this workspace E2E.""" - project_root = find_project_root() - return project_root.parent / 'langbot-plugin-demo' / 'Runner' / 'LocalAgent' + """Use the same official source pin as the plugin identity contracts.""" + return verified_plugin_root(files_key='local_agent_files') / 'Runner' / 'LocalAgent' def _package_local_agent_plugin(tmpdir: Path) -> Path: - """Package the sibling Local Agent plugin for the real local-install flow.""" + """Package the verified official plugin for the real local-install flow.""" local_agent_src = _local_agent_repo() if not (local_agent_src / 'manifest.yaml').exists(): - pytest.skip(f'local-agent repository not found at {local_agent_src}') + pytest.fail(f'LocalAgent manifest not found at {local_agent_src}', pytrace=False) package_source = tmpdir / 'local-agent-package' ignore = shutil.ignore_patterns( @@ -275,7 +275,12 @@ def local_agent_e2e_tmpdir(): """Create temporary directory for Local Agent E2E testing.""" tmpdir = Path(tempfile.mkdtemp(prefix='langbot_local_agent_e2e_')) yield tmpdir - shutil.rmtree(tmpdir, ignore_errors=True) + # The runtime seals its artifact/dependency directories as read-only. + # Restore only this fixture's own directories so teardown removes them. + for path in tmpdir.rglob('*'): + if path.is_dir() and not path.is_symlink(): + path.chmod(path.stat().st_mode | stat.S_IWUSR) + shutil.rmtree(tmpdir) @pytest.fixture(scope='session') @@ -552,6 +557,7 @@ def _run_local_agent_probe(tmpdir: Path, probe): return asyncio.run(_run()) +@pytest.mark.contract('agent_ledger') def test_local_runner_uses_host_fake_provider_and_persists_ledger( local_agent_e2e_tmpdir, local_agent_e2e_config_path, @@ -614,6 +620,7 @@ def test_local_runner_uses_host_fake_provider_and_persists_ledger( conn.close() +@pytest.mark.contract('agent_tools') def test_local_runner_executes_authorized_tool_loop_through_host_action( local_agent_e2e_tmpdir, local_agent_e2e_config_path, @@ -683,6 +690,7 @@ def test_local_runner_executes_authorized_tool_loop_through_host_action( conn.close() +@pytest.mark.contract('agent_rag') def test_local_runner_retrieves_authorized_rag_context_through_host_action( local_agent_e2e_tmpdir, local_agent_e2e_config_path, @@ -757,6 +765,7 @@ def test_local_runner_retrieves_authorized_rag_context_through_host_action( conn.close() +@pytest.mark.contract('agent_compaction') def test_local_runner_compacts_history_and_persists_checkpoint( local_agent_e2e_tmpdir, local_agent_e2e_config_path, @@ -850,6 +859,7 @@ def test_local_runner_compacts_history_and_persists_checkpoint( conn.close() +@pytest.mark.contract('agent_multi_turn') def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop( local_agent_e2e_tmpdir, local_agent_e2e_config_path, @@ -1012,6 +1022,7 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop( conn.close() +@pytest.mark.contract('agent_box') def test_local_runner_owns_box_reuse_and_explicit_files( local_agent_e2e_tmpdir, local_agent_e2e_config_path, @@ -1050,6 +1061,7 @@ def test_local_runner_owns_box_reuse_and_explicit_files( super().__init__() self.box = box self.bindings = [] + self.outboxes = [] async def get_resolved_tool_catalog(self, *args, **kwargs): return [{'name': 'exec', 'source': 'native', 'source_id': None}] @@ -1066,19 +1078,31 @@ def test_local_runner_owns_box_reuse_and_explicit_files( self.bindings.append((binding.session_id, binding.run_id)) source = next(iter(binding.imported.values()))['path'] outbox = f'/workspace/outbox/{binding.io_scope}' + self.outboxes.append(outbox) # Shell arguments are Host-issued paths, never model input. import shlex + directory_mode = '-m 700 ' if len(self.bindings) == 2 else '' result = await self.box.execute_tool( { - 'command': f'mkdir -p {shlex.quote(outbox)} && cp {shlex.quote(source)} {shlex.quote(outbox + "/answer.txt")}' + 'command': ( + f'mkdir {directory_mode}-p {shlex.quote(outbox)} && ' + f'cp {shlex.quote(source)} {shlex.quote(outbox + "/answer.txt")} && ' + f"stat -c '%u %a' {shlex.quote(outbox)} {shlex.quote(outbox + '/answer.txt')}" + ) }, query, ) assert result['ok'], result + ownership = result['stdout'].strip().splitlines() + assert ownership[-1] == '0 600', ownership + if len(self.bindings) == 2: + assert ownership[0] == '0 700', ownership return result async def probe(ap): + import shlex + backend = TestDockerBackend(ap.logger) if not await backend.is_available(): pytest.skip('Docker is required for the real Box probe') @@ -1120,6 +1144,12 @@ def test_local_runner_owns_box_reuse_and_explicit_files( output = [component for message in messages for component in (message.attachments or [])] assert len(output) == 1, messages assert base64.b64decode(output[0].base64) == f'run-{index}'.encode() + inspection = await box.execute_in_context( + context, + {'cmd': f'ls -A {shlex.quote(manager.outboxes[-1])}', 'session_id': manager.bindings[-1][0]}, + ) + assert inspection.exit_code == 0, inspection + assert inspection.stdout.strip() == '', inspection.stdout status = await RunnerBoxService(box).status(context) assert status['used'] == 1 and status['remaining'] == 0, status assert manager.bindings[0][0] == manager.bindings[1][0] @@ -1129,6 +1159,18 @@ def test_local_runner_owns_box_reuse_and_explicit_files( with pytest.raises(BoxCapacityExceededError): await RunnerBoxService(box).acquire(context, {'reuse_key': 'different'}) finally: - await box.shutdown() + try: + if manager.bindings: + # Only this fixture's temporary Workspace is mounted here. + cleanup = await box.execute_in_context( + context, + { + 'cmd': f'chown -hR {os.getuid()}:{os.getgid()} /workspace/outbox', + 'session_id': manager.bindings[-1][0], + }, + ) + assert cleanup.exit_code == 0, cleanup + finally: + await box.shutdown() _run_local_agent_probe(local_agent_e2e_tmpdir, probe) diff --git a/tests/e2e/test_startup.py b/tests/e2e/test_startup.py index 955e33af6..939effc6b 100644 --- a/tests/e2e/test_startup.py +++ b/tests/e2e/test_startup.py @@ -16,6 +16,75 @@ import pytest pytestmark = pytest.mark.e2e +@pytest.fixture(scope='session') +def e2e_tmpdir(tmp_path_factory): + # Initial registration is global; keep this module independent of other E2E accounts. + return tmp_path_factory.mktemp('langbot_startup') + + +@pytest.fixture(scope='session') +def e2e_port(): + import socket + + with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + return listener.getsockname()[1] + + +@pytest.fixture(scope='session') +def langbot_process(e2e_tmpdir, e2e_port): + from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories + from tests.e2e.utils.process_manager import LangBotProcess, find_project_root + + create_minimal_config(e2e_tmpdir, port=e2e_port) + create_test_directories(e2e_tmpdir) + process = LangBotProcess( + project_root=find_project_root(), + work_dir=e2e_tmpdir, + port=e2e_port, + timeout=180, + collect_coverage=True, + ) + try: + assert process.start(), process.get_logs() + yield process + finally: + process.stop() + coverage_file = process.get_coverage_file() + if coverage_file is not None and coverage_file.exists(): + import shutil + + shutil.copy(coverage_file, find_project_root() / '.coverage.e2e.startup') + + +@pytest.fixture(scope='session') +def e2e_db_path(e2e_tmpdir): + return e2e_tmpdir / 'data/langbot.db' + + +@pytest.fixture +def startup_credentials(e2e_client): + credentials = {'user': 'startup-e2e@example.com', 'password': 'startup-local-test-password'} + state = e2e_client.get('/api/v1/user/init') + assert state.status_code == 200 + if not state.json()['data']['initialized']: + response = e2e_client.post('/api/v1/user/init', json=credentials) + assert response.status_code == 200, response.text + assert response.json()['code'] == 0 + return credentials + + +@pytest.fixture +def startup_headers(e2e_client, startup_credentials): + response = e2e_client.post('/api/v1/user/auth', json=startup_credentials) + assert response.status_code == 200, response.text + headers = {'Authorization': f'Bearer {response.json()["data"]["token"]}'} + bootstrap = e2e_client.get('/api/v1/workspaces/bootstrap', headers=headers) + assert bootstrap.status_code == 200, bootstrap.text + headers['X-Workspace-Id'] = bootstrap.json()['data']['workspaces'][0]['workspace']['uuid'] + return headers + + class TestStartupFlow: """Tests for LangBot startup process.""" @@ -87,22 +156,21 @@ class TestStartupFlow: response = e2e_client.get('/api/v1/pipelines') assert response.status_code == 401 - def test_auth_endpoint(self, e2e_client, e2e_tmpdir): - """Test auth endpoint.""" - # First startup may allow initial setup - response = e2e_client.post( - '/api/v1/user/auth', - json={ - 'user': 'admin', - 'password': 'admin', - }, - ) + def test_auth_endpoint(self, e2e_client, startup_credentials): + """A controlled local account can sign in and use its token.""" + response = e2e_client.post('/api/v1/user/auth', json=startup_credentials) + assert response.status_code == 200, response.text + assert response.json()['code'] == 0 + token = response.json()['data']['token'] + assert isinstance(token, str) and token + bootstrap = e2e_client.get('/api/v1/workspaces/bootstrap', headers={'Authorization': f'Bearer {token}'}) + assert bootstrap.status_code == 200, bootstrap.text + assert bootstrap.json()['data']['workspaces'][0]['membership']['role'] == 'owner' - # Response could be: - # - 200 if auth succeeds - # - 400 if credentials wrong - # - 401 if user not initialized - assert response.status_code in [200, 400, 401] + rejected = e2e_client.post('/api/v1/user/auth', json={**startup_credentials, 'password': 'wrong-password'}) + assert rejected.status_code == 200, rejected.text + assert rejected.json()['code'] == 1 + assert rejected.json().get('data', {}).get('token') is None class TestStartupStages: @@ -127,24 +195,37 @@ class TestStartupStages: cursor.execute('SELECT version_num FROM alembic_version;') version = cursor.fetchone() - assert version is not None, 'Migration version should be set' + from pathlib import Path + + from alembic.script import ScriptDirectory + + migrations = Path(__file__).parents[2] / 'src/langbot/pkg/persistence/alembic' + assert version == (ScriptDirectory(str(migrations)).get_current_head(),) conn.close() - def test_http_controller_initialized(self, e2e_client): - """Verify HTTP controller was initialized.""" - # Multiple endpoints should be available - endpoints = [ - '/api/v1/system/info', - '/api/v1/pipelines', - '/api/v1/provider/providers', - '/api/v1/platform/bots', - ] + def test_http_controller_initialized(self, e2e_client, startup_headers): + """Authenticated business endpoints return their documented collections.""" + info = e2e_client.get('/api/v1/system/info', headers=startup_headers) + assert info.status_code == 200 + assert isinstance(info.json()['data']['version'], str) - for endpoint in endpoints: - response = e2e_client.get(endpoint) - # Should get a real route response, even if auth is required. - assert response.status_code in [200, 401, 403], f'{endpoint} should be registered' + for endpoint, key in [ + ('/api/v1/pipelines', 'pipelines'), + ('/api/v1/provider/providers', 'providers'), + ('/api/v1/platform/bots', 'bots'), + ]: + response = e2e_client.get(endpoint, headers=startup_headers) + assert response.status_code == 200, (endpoint, response.text) + payload = response.json() + assert payload['code'] == 0 + assert isinstance(payload['data'][key], list) + if key == 'providers': + assert [(item['name'], item['requester'], item['api_keys']) for item in payload['data'][key]] == [ + ('LangBot Models', 'space-chat-completions', []) + ] + else: + assert payload['data'][key] == [] class TestMinimalStartupNoLLM: @@ -155,8 +236,14 @@ class TestMinimalStartupNoLLM: response = e2e_client.get('/api/v1/system/info') assert response.status_code == 200 - def test_pipeline_metadata_available(self, e2e_client): - """Pipeline metadata endpoint should work without LLM.""" - # Requires auth, but endpoint should exist - response = e2e_client.get('/api/v1/pipelines/_/metadata') - assert response.status_code in [200, 401] # Not 404 or 500 + def test_pipeline_metadata_available(self, e2e_client, startup_headers): + """All pipeline configuration groups remain readable without an LLM.""" + response = e2e_client.get('/api/v1/pipelines/_/metadata', headers=startup_headers) + assert response.status_code == 200, response.text + payload = response.json() + assert payload['code'] == 0 + configs = payload['data']['configs'] + assert [config['name'] for config in configs] == ['trigger', 'safety', 'ai', 'output'] + ai = configs[2] + runner = next(stage for stage in ai['stages'] if stage['name'] == 'runner') + assert any(item['name'] == 'id' for item in runner['config']) diff --git a/tests/fixtures/legacy_plugin_identity_sources.json b/tests/fixtures/legacy_plugin_identity_sources.json index 83cb7337f..62c60b864 100644 --- a/tests/fixtures/legacy_plugin_identity_sources.json +++ b/tests/fixtures/legacy_plugin_identity_sources.json @@ -15,5 +15,27 @@ "Runner/tbox-agent/components/runner/default.py": "4c9fdf8cc10c916915036112ded8768a619a827ca4b5e6a3f05881a7d0e4492f", "Runner/tbox-agent/pkg/scoped_identity.py": "5d03d0c56aaf3301bc5d594905ecf862151fa2abc73daba1a6596f0fdc16374a", "Runner/tbox-agent/pkg/tbox_client.py": "0f09a3c70f773bc6377fd5b55a41d186dd15487d46aab3e61b837c08e15100a0" + }, + "local_agent_files": { + "Runner/LocalAgent/assets/icon.svg": "7e1cb51793e898fca810ec72ba6fe9af377074f52139cdd56c93e87f3459f964", + "Runner/LocalAgent/components/runner/default.py": "c63dc2697c61184e496d2fbed9808ccac23fb706a7dcccf34292da2b8e0079b9", + "Runner/LocalAgent/components/runner/default.yaml": "c1364cf0f211162bb701e27ed92b351fd9ff4cfa0b4469cfeb83ad58136cdf47", + "Runner/LocalAgent/main.py": "0cde45d7f6f411563fbded09be70d0a497c7c7999b5eaa27f83052383348277f", + "Runner/LocalAgent/manifest.yaml": "e71e03f2670dade9ae43e58ddf787bb87254ba9f05e1a691822679f3641bb885", + "Runner/LocalAgent/pkg/__init__.py": "ce5142b4971e8d7945fb3c202bacbc28011b033de0ddcb612d5d29106d6985ec", + "Runner/LocalAgent/pkg/agent_core/__init__.py": "56e95f2391f796e17aee6a168603853ea78b59df97a136bfa33532aede2fa413", + "Runner/LocalAgent/pkg/agent_core/langbot.py": "dc16818ac81dacf37dd6ee799dec4fcd8235ffab4602ede30d29633e78626fc0", + "Runner/LocalAgent/pkg/agent_core/loop.py": "47451d5935d79b0a632fc87f3d94a7d0ecf1b50621141ea8b1031dd87755d5ad", + "Runner/LocalAgent/pkg/agent_core/types.py": "df82b1a17cd2b73f65ebbde22ab8f204ffd27b8d68814b404a1ecce9988e1fcf", + "Runner/LocalAgent/pkg/box.py": "82c05a55bd59b41e844f4613563e518768921fe4fd34bcb4c14c86e395ccd021", + "Runner/LocalAgent/pkg/config.py": "8dee31aecbcdba4c1e79e1735499c3c7ee826ec395032b045169652b38216faf", + "Runner/LocalAgent/pkg/context_pipeline.py": "4c6da3c48faf28985b8bad013fe15106705cd3e025f0c55708b463e4ea07eb05", + "Runner/LocalAgent/pkg/messages.py": "620a0a225e95c5f2c8c6e977801413e553d4a1da3fb2ddea14e527ad41f7a3af", + "Runner/LocalAgent/pkg/model_calling.py": "29989b102c8f649e1e65c7c5719761ed39d357d37d57bfa321976d48fb11b76c", + "Runner/LocalAgent/pkg/rag.py": "ea407af8d4f6fe5b00cde72c7d85ccc541d6fc46254188c372351d93da4a1f0f", + "Runner/LocalAgent/pkg/run_assembly.py": "ab08c6a18a108cfc6048f158b09ad874577451072dce0bbaa3aa9d15c33a3afe", + "Runner/LocalAgent/pyproject.toml": "ef14f2ed5d4d78591d2f1c0ac0f05856d558dae71da330a203abf3160356e6b1", + "Runner/LocalAgent/requirements.txt": "dd4542c179fa98c8341e7a0b582eb741a70633cd621f4509d09a9ff5c7e52c76", + "Runner/LocalAgent/uv.lock": "4c3c7655ef8c3984e7b720dea346ea67b4d29e8c6d543037779008c3bca074a8" } } diff --git a/tests/integration/api/test_bots.py b/tests/integration/api/test_bots.py index 3e3a3a871..f74eb9abe 100644 --- a/tests/integration/api/test_bots.py +++ b/tests/integration/api/test_bots.py @@ -125,7 +125,7 @@ def fake_bot_app(): 'webhook_url': 'https://example.com/webhook/test-bot-uuid', } ) - app.bot_service.create_bot = AsyncMock(return_value={'uuid': 'new-bot-uuid'}) + app.bot_service.create_bot = AsyncMock(return_value='new-bot-uuid') app.bot_service.update_bot = AsyncMock(return_value={}) app.bot_service.delete_bot = AsyncMock() app.bot_service.list_event_logs = AsyncMock(return_value=([{'uuid': 'log-1', 'message': 'test log'}], 1)) @@ -179,6 +179,11 @@ def fake_bot_app(): return app +@pytest.fixture(autouse=True) +def reset_service_calls(fake_bot_app): + fake_bot_app.bot_service.reset_mock() + + @pytest.fixture(scope='module') async def quart_test_client(fake_bot_app, http_controller_cls): """Create Quart test client (module scope to avoid route re-registration).""" @@ -201,11 +206,17 @@ class TestBotEndpoints: assert response.status_code == 200 data = await response.get_json() assert data['code'] == 0 - assert 'data' in data - assert 'bots' in data['data'] + assert data['data']['bots'] == [ + { + 'uuid': 'test-bot-uuid', + 'name': 'Test Bot', + 'platform': 'telegram', + 'pipeline_uuid': 'test-pipeline-uuid', + } + ] @pytest.mark.asyncio - async def test_create_bot_success(self, quart_test_client): + async def test_create_bot_success(self, quart_test_client, fake_bot_app): """POST /api/v1/platform/bots creates new bot.""" response = await quart_test_client.post( '/api/v1/platform/bots', @@ -218,8 +229,17 @@ class TestBotEndpoints: assert data['code'] == 0 assert 'uuid' in data['data'] + called = fake_bot_app.bot_service.create_bot + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ( + {'name': 'New Bot', 'platform': 'telegram', 'pipeline_uuid': 'test-pipeline'}, + ) + assert called.await_args.kwargs == {} + assert data['data'] == {'uuid': 'new-bot-uuid'} + @pytest.mark.asyncio - async def test_get_single_bot_success(self, quart_test_client): + async def test_get_single_bot_success(self, quart_test_client, fake_bot_app): """GET /api/v1/platform/bots/{uuid} returns bot with runtime info.""" response = await quart_test_client.get( '/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'} @@ -230,8 +250,21 @@ class TestBotEndpoints: assert data['code'] == 0 assert 'bot' in data['data'] + called = fake_bot_app.bot_service.get_runtime_bot_info + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-bot-uuid',) + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['bot'] == { + 'uuid': 'test-bot-uuid', + 'name': 'Test Bot', + 'platform': 'telegram', + 'pipeline_uuid': 'test-pipeline-uuid', + 'webhook_url': 'https://example.com/webhook/test-bot-uuid', + } + @pytest.mark.asyncio - async def test_update_bot_success(self, quart_test_client): + async def test_update_bot_success(self, quart_test_client, fake_bot_app): """PUT /api/v1/platform/bots/{uuid} updates bot.""" response = await quart_test_client.put( '/api/v1/platform/bots/test-bot-uuid', @@ -243,8 +276,14 @@ class TestBotEndpoints: data = await response.get_json() assert data['code'] == 0 + called = fake_bot_app.bot_service.update_bot + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-bot-uuid', {'name': 'Updated Bot'}) + assert called.await_args.kwargs == {} + @pytest.mark.asyncio - async def test_delete_bot_success(self, quart_test_client): + async def test_delete_bot_success(self, quart_test_client, fake_bot_app): """DELETE /api/v1/platform/bots/{uuid} deletes bot.""" response = await quart_test_client.delete( '/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'} @@ -252,13 +291,20 @@ class TestBotEndpoints: assert response.status_code == 200 + called = fake_bot_app.bot_service.delete_bot + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-bot-uuid',) + assert called.await_args.kwargs == {} + assert (await response.get_json())['code'] == 0 + @pytest.mark.usefixtures('mock_circular_import_chain') class TestBotLogsEndpoint: """Tests for bot logs endpoint.""" @pytest.mark.asyncio - async def test_get_bot_logs_success(self, quart_test_client): + async def test_get_bot_logs_success(self, quart_test_client, fake_bot_app): """POST /api/v1/platform/bots/{uuid}/logs returns logs.""" response = await quart_test_client.post( '/api/v1/platform/bots/test-bot-uuid/logs', @@ -272,6 +318,13 @@ class TestBotLogsEndpoint: assert 'logs' in data['data'] assert 'total_count' in data['data'] + called = fake_bot_app.bot_service.list_event_logs + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-bot-uuid', -1, 10) + assert called.await_args.kwargs == {} + assert data['data'] == {'logs': [{'uuid': 'log-1', 'message': 'test log'}], 'total_count': 1} + @pytest.mark.asyncio async def test_viewer_can_read_ordinary_bot_logs(self, quart_test_client, fake_bot_app): access = fake_bot_app.workspace_collaboration_service.resolve_account_workspace.return_value @@ -366,7 +419,7 @@ class TestBotSendMessageEndpoint: """Tests for bot send message endpoint.""" @pytest.mark.asyncio - async def test_send_message_success(self, quart_test_client): + async def test_send_message_success(self, quart_test_client, fake_bot_app): """POST /api/v1/platform/bots/{uuid}/send_message sends message.""" response = await quart_test_client.post( '/api/v1/platform/bots/test-bot-uuid/send_message', @@ -383,6 +436,12 @@ class TestBotSendMessageEndpoint: assert data['code'] == 0 assert data['data']['sent'] is True + called = fake_bot_app.bot_service.send_message + called.assert_awaited_once() + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-bot-uuid', 'person', 'user123', [{'type': 'text', 'text': 'Hello'}]) + assert called.await_args.kwargs == {} + @pytest.mark.asyncio async def test_send_message_missing_target_type(self, quart_test_client): """POST send_message without target_type returns 400.""" diff --git a/tests/integration/api/test_embed.py b/tests/integration/api/test_embed.py index d27b1e002..73204674f 100644 --- a/tests/integration/api/test_embed.py +++ b/tests/integration/api/test_embed.py @@ -10,6 +10,7 @@ from __future__ import annotations import json import os +from pathlib import Path import pytest from unittest.mock import MagicMock, AsyncMock, Mock @@ -148,6 +149,11 @@ class TestEmbedWidgetEndpoint: assert 'javascript' in response.content_type fake_embed_app.platform_mgr.resolve_public_bot.assert_any_await('a1b2c3d4-5678-90ab-cdef-123456789abc') + body = await response.get_data(as_text=True) + assert 'a1b2c3d4-5678-90ab-cdef-123456789abc' in body + assert 'baseUrl: "http://localhost"' in body + assert '__LANGBOT_' not in body + @pytest.mark.asyncio @pytest.mark.parametrize( ('query', 'expected_base'), @@ -208,14 +214,21 @@ class TestEmbedLogoEndpoint: assert response.status_code == 200 assert 'image/webp' in response.content_type + body = await response.get_data() + assert body == (Path(__file__).parents[3] / 'src/langbot/templates/embed/logo.webp').read_bytes() + assert body[:4] == b'RIFF' and body[8:12] == b'WEBP' + @pytest.mark.usefixtures('mock_circular_import_chain') class TestEmbedTurnstileVerifyEndpoint: """Tests for Turnstile verification endpoint.""" @pytest.mark.asyncio - async def test_turnstile_verify_no_secret(self, quart_test_client): + async def test_turnstile_verify_no_secret(self, quart_test_client, monkeypatch): """POST turnstile verify without secret returns dummy token.""" + import langbot.pkg.api.http.controller.groups.pipelines.embed as embed + + monkeypatch.setattr(embed.time, 'time', lambda: 1720000000.25) response = await quart_test_client.post( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', json={'token': 'test-token'} ) @@ -225,6 +238,8 @@ class TestEmbedTurnstileVerifyEndpoint: assert data['code'] == 0 assert 'token' in data['data'] + assert data['data']['token'] == '1720000000.25.dummy' + @pytest.mark.asyncio async def test_turnstile_verify_invalid_uuid(self, quart_test_client): """POST turnstile verify with invalid UUID returns 400.""" @@ -264,7 +279,7 @@ class TestEmbedMessagesEndpoint: proxy_bot.adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'person', SESSION_ID) @pytest.mark.asyncio - async def test_get_messages_group_success(self, quart_test_client): + async def test_get_messages_group_success(self, quart_test_client, fake_embed_app): """GET messages/group returns messages.""" response = await quart_test_client.get( f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group?session_id={SESSION_ID}', @@ -273,6 +288,12 @@ class TestEmbedMessagesEndpoint: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['messages'] == [{'id': 'msg-1', 'content': 'test message'}] + adapter = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value.adapter + adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'group', SESSION_ID) + @pytest.mark.asyncio async def test_get_messages_requires_session_id(self, quart_test_client): """GET messages without a client session identifier returns 400.""" @@ -287,12 +308,14 @@ class TestEmbedMessagesEndpoint: async def test_get_messages_invalid_session_type(self, quart_test_client): """GET messages with invalid session_type returns 400.""" response = await quart_test_client.get( - '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid', + f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid?session_id={SESSION_ID}', headers={'Authorization': 'Bearer 1234567890.dummy'}, ) assert response.status_code == 400 + assert (await response.get_json())['msg'] == 'session_type must be person or group' + @pytest.mark.usefixtures('mock_circular_import_chain') class TestEmbedResetEndpoint: @@ -337,8 +360,12 @@ class TestEmbedFeedbackEndpoint: """Tests for feedback submission endpoint.""" @pytest.mark.asyncio - async def test_submit_feedback_like(self, quart_test_client): + async def test_submit_feedback_like(self, quart_test_client, fake_embed_app, monkeypatch): """POST feedback with type=1 (like) succeeds.""" + import langbot.pkg.api.http.controller.groups.pipelines.embed as embed + + monkeypatch.setattr(embed.uuid, 'uuid4', lambda: SimpleNamespace(hex='123456789abcdef0123456789abcdef0')) + fake_embed_app.monitoring_service.record_feedback.reset_mock() response = await quart_test_client.post( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback', headers={'Authorization': 'Bearer 1234567890.dummy'}, @@ -350,9 +377,29 @@ class TestEmbedFeedbackEndpoint: assert data['code'] == 0 assert 'feedback_id' in data['data'] + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['feedback_id'] == 'embed_123456789abc' + called = fake_embed_app.monitoring_service.record_feedback + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.kwargs == { + 'feedback_id': 'embed_123456789abc', + 'feedback_type': 1, + 'bot_id': 'a1b2c3d4-5678-90ab-cdef-123456789abc', + 'bot_name': 'Test Web Bot', + 'pipeline_id': 'test-pipeline-uuid', + 'message_id': 'msg-123', + 'platform': 'web_page_bot', + } + @pytest.mark.asyncio - async def test_submit_feedback_dislike(self, quart_test_client): + async def test_submit_feedback_dislike(self, quart_test_client, fake_embed_app, monkeypatch): """POST feedback with type=2 (dislike) succeeds.""" + import langbot.pkg.api.http.controller.groups.pipelines.embed as embed + + monkeypatch.setattr(embed.uuid, 'uuid4', lambda: SimpleNamespace(hex='123456789abcdef0123456789abcdef0')) + fake_embed_app.monitoring_service.record_feedback.reset_mock() response = await quart_test_client.post( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback', headers={'Authorization': 'Bearer 1234567890.dummy'}, @@ -361,6 +408,22 @@ class TestEmbedFeedbackEndpoint: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['feedback_id'] == 'embed_123456789abc' + called = fake_embed_app.monitoring_service.record_feedback + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.kwargs == { + 'feedback_id': 'embed_123456789abc', + 'feedback_type': 2, + 'bot_id': 'a1b2c3d4-5678-90ab-cdef-123456789abc', + 'bot_name': 'Test Web Bot', + 'pipeline_id': 'test-pipeline-uuid', + 'message_id': 'msg-123', + 'platform': 'web_page_bot', + } + @pytest.mark.asyncio async def test_submit_feedback_invalid_type(self, quart_test_client): """POST feedback with invalid type returns 400.""" @@ -378,7 +441,30 @@ class TestEmbedWebSocketEndpoint: """The public socket authenticates before resolving shared runtime state.""" @pytest.mark.asyncio - async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app): + async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app, monkeypatch): + import langbot.pkg.api.http.controller.groups.pipelines.embed as embed + + authenticated = False + original_authenticate = embed.EmbedRouterGroup._authenticate_websocket + original_add = embed.ws_connection_manager.add_connection + proxy = fake_embed_app.platform_mgr.get_websocket_proxy_bot + + async def authenticate(router, runtime_bot): + nonlocal authenticated + await original_authenticate(router, runtime_bot) + authenticated = True + + async def get_proxy(*_args, **_kwargs): + assert authenticated, 'Runtime lookup must follow successful authentication' + return proxy.return_value + + async def add_connection(*args, **kwargs): + assert authenticated, 'Connection registration must follow successful authentication' + return await original_add(*args, **kwargs) + + monkeypatch.setattr(embed.EmbedRouterGroup, '_authenticate_websocket', authenticate) + monkeypatch.setattr(proxy, 'side_effect', get_proxy) + monkeypatch.setattr(embed.ws_connection_manager, 'add_connection', add_connection) async with quart_test_client.websocket( f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect' f'?session_type=person&session_id={SESSION_ID}', diff --git a/tests/integration/api/test_knowledge.py b/tests/integration/api/test_knowledge.py index 13b0785c7..a046da6d4 100644 --- a/tests/integration/api/test_knowledge.py +++ b/tests/integration/api/test_knowledge.py @@ -117,13 +117,13 @@ def fake_knowledge_app(): 'engine_plugin_id': 'test/engine', } ) - app.knowledge_service.create_knowledge_base = AsyncMock(return_value={'uuid': 'new-kb-uuid'}) + app.knowledge_service.create_knowledge_base = AsyncMock(return_value='new-kb-uuid') app.knowledge_service.update_knowledge_base = AsyncMock(return_value={}) app.knowledge_service.delete_knowledge_base = AsyncMock() app.knowledge_service.get_files_by_knowledge_base = AsyncMock( return_value=[{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}] ) - app.knowledge_service.store_file = AsyncMock(return_value={'task_id': 'test-task-id'}) + app.knowledge_service.store_file = AsyncMock(return_value='test-task-id') app.knowledge_service.delete_file = AsyncMock() app.knowledge_service.retrieve_knowledge_base = AsyncMock(return_value=[{'content': 'test result', 'score': 0.95}]) @@ -133,6 +133,11 @@ def fake_knowledge_app(): return app +@pytest.fixture(autouse=True) +def reset_service_calls(fake_knowledge_app): + fake_knowledge_app.knowledge_service.reset_mock() + + @pytest.fixture(scope='module') async def quart_test_client(fake_knowledge_app, http_controller_cls): """Create Quart test client (module scope to avoid route re-registration).""" @@ -148,7 +153,7 @@ class TestKnowledgeBaseEndpoints: """Tests for /api/v1/knowledge/bases endpoints.""" @pytest.mark.asyncio - async def test_get_knowledge_bases_success(self, quart_test_client): + async def test_get_knowledge_bases_success(self, quart_test_client, fake_knowledge_app): """GET /api/v1/knowledge/bases returns knowledge base list.""" response = await quart_test_client.get( '/api/v1/knowledge/bases', headers={'Authorization': 'Bearer test_token'} @@ -160,8 +165,24 @@ class TestKnowledgeBaseEndpoints: assert 'data' in data assert 'bases' in data['data'] + called = fake_knowledge_app.knowledge_service.get_knowledge_bases + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['bases'] == [ + { + 'uuid': 'test-kb-uuid', + 'name': 'Test Knowledge Base', + 'description': 'Test KB description', + 'engine_plugin_id': 'test/engine', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + } + ] + @pytest.mark.asyncio - async def test_create_knowledge_base_success(self, quart_test_client): + async def test_create_knowledge_base_success(self, quart_test_client, fake_knowledge_app): """POST /api/v1/knowledge/bases creates new knowledge base.""" response = await quart_test_client.post( '/api/v1/knowledge/bases', @@ -174,8 +195,15 @@ class TestKnowledgeBaseEndpoints: assert data['code'] == 0 assert 'uuid' in data['data'] + called = fake_knowledge_app.knowledge_service.create_knowledge_base + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ({'name': 'New KB', 'engine_plugin_id': 'test/engine'},) + assert called.await_args.kwargs == {} + assert data['data'] == {'uuid': 'new-kb-uuid'} + @pytest.mark.asyncio - async def test_get_single_knowledge_base_success(self, quart_test_client): + async def test_get_single_knowledge_base_success(self, quart_test_client, fake_knowledge_app): """GET /api/v1/knowledge/bases/{uuid} returns knowledge base.""" response = await quart_test_client.get( '/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'} @@ -186,8 +214,20 @@ class TestKnowledgeBaseEndpoints: assert data['code'] == 0 assert 'base' in data['data'] + called = fake_knowledge_app.knowledge_service.get_knowledge_base + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid',) + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['base'] == { + 'uuid': 'test-kb-uuid', + 'name': 'Test Knowledge Base', + 'description': 'Test KB description', + 'engine_plugin_id': 'test/engine', + } + @pytest.mark.asyncio - async def test_update_knowledge_base_success(self, quart_test_client): + async def test_update_knowledge_base_success(self, quart_test_client, fake_knowledge_app): """PUT /api/v1/knowledge/bases/{uuid} updates knowledge base.""" response = await quart_test_client.put( '/api/v1/knowledge/bases/test-kb-uuid', @@ -199,8 +239,14 @@ class TestKnowledgeBaseEndpoints: data = await response.get_json() assert data['code'] == 0 + called = fake_knowledge_app.knowledge_service.update_knowledge_base + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid', {'name': 'Updated KB'}) + assert called.await_args.kwargs == {} + @pytest.mark.asyncio - async def test_delete_knowledge_base_success(self, quart_test_client): + async def test_delete_knowledge_base_success(self, quart_test_client, fake_knowledge_app): """DELETE /api/v1/knowledge/bases/{uuid} deletes knowledge base.""" response = await quart_test_client.delete( '/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'} @@ -208,13 +254,20 @@ class TestKnowledgeBaseEndpoints: assert response.status_code == 200 + called = fake_knowledge_app.knowledge_service.delete_knowledge_base + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid',) + assert called.await_args.kwargs == {} + assert (await response.get_json())['code'] == 0 + @pytest.mark.usefixtures('mock_circular_import_chain') class TestKnowledgeBaseFilesEndpoints: """Tests for knowledge base files endpoints.""" @pytest.mark.asyncio - async def test_get_files_success(self, quart_test_client): + async def test_get_files_success(self, quart_test_client, fake_knowledge_app): """GET /api/v1/knowledge/bases/{uuid}/files returns files.""" response = await quart_test_client.get( '/api/v1/knowledge/bases/test-kb-uuid/files', headers={'Authorization': 'Bearer test_token'} @@ -225,8 +278,15 @@ class TestKnowledgeBaseFilesEndpoints: assert data['code'] == 0 assert 'files' in data['data'] + called = fake_knowledge_app.knowledge_service.get_files_by_knowledge_base + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid',) + assert called.await_args.kwargs == {} + assert data['data']['files'] == [{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}] + @pytest.mark.asyncio - async def test_add_file_to_knowledge_base(self, quart_test_client): + async def test_add_file_to_knowledge_base(self, quart_test_client, fake_knowledge_app): """POST /api/v1/knowledge/bases/{uuid}/files adds file.""" response = await quart_test_client.post( '/api/v1/knowledge/bases/test-kb-uuid/files', @@ -239,8 +299,15 @@ class TestKnowledgeBaseFilesEndpoints: assert data['code'] == 0 assert 'task_id' in data['data'] + called = fake_knowledge_app.knowledge_service.store_file + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid', 'test-file-id') + assert called.await_args.kwargs == {'parser_plugin_id': 'test/parser'} + assert data['data'] == {'task_id': 'test-task-id'} + @pytest.mark.asyncio - async def test_delete_file_from_knowledge_base(self, quart_test_client): + async def test_delete_file_from_knowledge_base(self, quart_test_client, fake_knowledge_app): """DELETE /api/v1/knowledge/bases/{uuid}/files/{file_id}.""" response = await quart_test_client.delete( '/api/v1/knowledge/bases/test-kb-uuid/files/test-file-uuid', headers={'Authorization': 'Bearer test_token'} @@ -248,13 +315,20 @@ class TestKnowledgeBaseFilesEndpoints: assert response.status_code == 200 + called = fake_knowledge_app.knowledge_service.delete_file + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid', 'test-file-uuid') + assert called.await_args.kwargs == {} + assert (await response.get_json())['code'] == 0 + @pytest.mark.usefixtures('mock_circular_import_chain') class TestKnowledgeBaseRetrieveEndpoint: """Tests for knowledge base retrieval endpoint.""" @pytest.mark.asyncio - async def test_retrieve_knowledge_success(self, quart_test_client): + async def test_retrieve_knowledge_success(self, quart_test_client, fake_knowledge_app): """POST /api/v1/knowledge/bases/{uuid}/retrieve.""" response = await quart_test_client.post( '/api/v1/knowledge/bases/test-kb-uuid/retrieve', @@ -267,6 +341,13 @@ class TestKnowledgeBaseRetrieveEndpoint: assert data['code'] == 0 assert 'results' in data['data'] + called = fake_knowledge_app.knowledge_service.retrieve_knowledge_base + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid', 'test query', {'top_k': 5}) + assert called.await_args.kwargs == {} + assert data['data']['results'] == [{'content': 'test result', 'score': 0.95}] + @pytest.mark.asyncio async def test_retrieve_without_query_returns_error(self, quart_test_client): """POST retrieve without query returns 400.""" diff --git a/tests/integration/api/test_monitoring.py b/tests/integration/api/test_monitoring.py index 84320afbe..f10540d41 100644 --- a/tests/integration/api/test_monitoring.py +++ b/tests/integration/api/test_monitoring.py @@ -8,6 +8,9 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q from __future__ import annotations +import csv +import io + import pytest from unittest.mock import MagicMock, AsyncMock, Mock, patch from types import SimpleNamespace @@ -122,17 +125,24 @@ def fake_monitoring_app(): ) app.monitoring_service.get_feedback_stats = AsyncMock(return_value={'like_count': 10}) app.monitoring_service.get_feedback_list = AsyncMock(return_value=([{'feedback_id': 'fb-1'}], 12)) - app.monitoring_service.export_messages = AsyncMock(return_value=[{'id': 'msg-1'}]) + app.monitoring_service.export_messages = AsyncMock( + return_value=[{'id': 'msg-1', 'message_content': 'hello, "quoted"'}] + ) app.monitoring_service.export_llm_calls = AsyncMock(return_value=[{'id': 'llm-1'}]) app.monitoring_service.export_errors = AsyncMock(return_value=[{'id': 'err-1'}]) app.monitoring_service.export_sessions = AsyncMock(return_value=[{'session_id': 'sess-1'}]) app.monitoring_service.export_feedback = AsyncMock(return_value=[{'id': 'fb-1'}]) app.monitoring_service.export_embedding_calls = AsyncMock(return_value=[{'id': 'emb-1'}]) - app.monitoring_service._escape_csv_field = Mock(return_value='escaped') + app.monitoring_service._escape_csv_field = MonitoringService(app)._escape_csv_field return app +@pytest.fixture(autouse=True) +def reset_monitoring_calls(fake_monitoring_app): + fake_monitoring_app.monitoring_service.reset_mock() + + @pytest.fixture(scope='module') async def quart_test_client(fake_monitoring_app, http_controller_cls): """Create Quart test client (module scope).""" @@ -158,6 +168,14 @@ class TestMonitoringOverviewEndpoint: data = await response.get_json() assert data['code'] == 0 + assert data['data'] == { + 'total_messages': 100, + 'total_llm_calls': 50, + 'total_sessions': 20, + 'active_sessions': 5, + 'total_errors': 2, + } + @pytest.mark.asyncio async def test_viewer_can_read_monitoring_but_cannot_export( self, @@ -203,6 +221,15 @@ class TestMonitoringMessagesEndpoint: assert data['code'] == 0 assert 'messages' in data['data'] + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == { + 'messages': [{'id': 'msg-1', 'content': 'test'}], + 'total': 100, + 'limit': 100, + 'offset': 0, + } + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringLLMCallsEndpoint: @@ -217,6 +244,10 @@ class TestMonitoringLLMCallsEndpoint: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'llm_calls': [{'id': 'llm-1'}], 'total': 50, 'limit': 100, 'offset': 0} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringEmbeddingCallsEndpoint: @@ -231,6 +262,10 @@ class TestMonitoringEmbeddingCallsEndpoint: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'embedding_calls': [{'id': 'emb-1'}], 'total': 10, 'limit': 100, 'offset': 0} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringSessionsEndpoint: @@ -245,6 +280,10 @@ class TestMonitoringSessionsEndpoint: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'sessions': [{'session_id': 'sess-1'}], 'total': 20, 'limit': 100, 'offset': 0} + @pytest.mark.asyncio async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app): fake_monitoring_app.monitoring_service.get_sessions.reset_mock() @@ -275,6 +314,10 @@ class TestMonitoringErrorsEndpoint: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'errors': [{'id': 'err-1'}], 'total': 2, 'limit': 100, 'offset': 0} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringAllDataEndpoint: @@ -298,6 +341,29 @@ class TestMonitoringAllDataEndpoint: assert 'overview' in data['data'] assert data['data']['traffic'] == traffic + assert data['code'] == 0 + assert data['data']['overview'] == { + 'total_messages': 100, + 'total_llm_calls': 50, + 'total_sessions': 20, + 'active_sessions': 5, + 'total_errors': 2, + } + assert data['data']['messages'] == [{'id': 'msg-1', 'content': 'test'}] + assert data['data']['llmCalls'] == [{'id': 'llm-1'}] + assert data['data']['toolCalls'] == [{'id': 'tool-1'}] + assert data['data']['embeddingCalls'] == [{'id': 'emb-1'}] + assert data['data']['sessions'] == [{'session_id': 'sess-1'}] + assert data['data']['errors'] == [{'id': 'err-1'}] + assert data['data']['totalCount'] == { + 'messages': 100, + 'llmCalls': 50, + 'toolCalls': 5, + 'embeddingCalls': 10, + 'sessions': 20, + 'errors': 2, + } + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringDetailsEndpoints: @@ -327,6 +393,10 @@ class TestMonitoringDetailsEndpoints: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'found': True, 'message_id': 'msg-1'} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringFeedbackEndpoints: @@ -341,6 +411,10 @@ class TestMonitoringFeedbackEndpoints: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'like_count': 10} + @pytest.mark.asyncio async def test_get_feedback_list(self, quart_test_client): """GET /api/v1/monitoring/feedback.""" @@ -350,13 +424,17 @@ class TestMonitoringFeedbackEndpoints: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'feedback': [{'feedback_id': 'fb-1'}], 'total': 12, 'limit': 100, 'offset': 0} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringExportEndpoint: """Tests for /api/v1/monitoring/export endpoint.""" @pytest.mark.asyncio - async def test_export_messages(self, quart_test_client): + async def test_export_messages(self, quart_test_client, fake_monitoring_app): """GET export?type=messages returns CSV.""" response = await quart_test_client.get( '/api/v1/monitoring/export?type=messages', headers={'Authorization': 'Bearer test_token'} @@ -365,8 +443,20 @@ class TestMonitoringExportEndpoint: assert response.status_code == 200 assert 'text/csv' in response.content_type + assert 'text/csv' in response.content_type + assert 'monitoring-messages-' in response.headers['Content-Disposition'] + body = (await response.get_data()).decode('utf-8-sig') + records = list(csv.DictReader(io.StringIO(body))) + assert len(records) == 1 + assert records[0]['id'] == 'msg-1' + called = fake_monitoring_app.monitoring_service.export_messages + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.kwargs['limit'] == 100000 + assert records[0]['message_content'] == 'hello, "quoted"' + @pytest.mark.asyncio - async def test_export_llm_calls(self, quart_test_client): + async def test_export_llm_calls(self, quart_test_client, fake_monitoring_app): """GET export?type=llm-calls returns CSV.""" response = await quart_test_client.get( '/api/v1/monitoring/export?type=llm-calls', headers={'Authorization': 'Bearer test_token'} @@ -374,8 +464,19 @@ class TestMonitoringExportEndpoint: assert response.status_code == 200 + assert 'text/csv' in response.content_type + assert 'monitoring-llm-calls-' in response.headers['Content-Disposition'] + body = (await response.get_data()).decode('utf-8-sig') + records = list(csv.DictReader(io.StringIO(body))) + assert len(records) == 1 + assert records[0]['id'] == 'llm-1' + called = fake_monitoring_app.monitoring_service.export_llm_calls + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.kwargs['limit'] == 100000 + @pytest.mark.asyncio - async def test_export_sessions(self, quart_test_client): + async def test_export_sessions(self, quart_test_client, fake_monitoring_app): """GET export?type=sessions returns CSV.""" response = await quart_test_client.get( '/api/v1/monitoring/export?type=sessions', headers={'Authorization': 'Bearer test_token'} @@ -383,11 +484,33 @@ class TestMonitoringExportEndpoint: assert response.status_code == 200 + assert 'text/csv' in response.content_type + assert 'monitoring-sessions-' in response.headers['Content-Disposition'] + body = (await response.get_data()).decode('utf-8-sig') + records = list(csv.DictReader(io.StringIO(body))) + assert len(records) == 1 + assert records[0]['session_id'] == 'sess-1' + called = fake_monitoring_app.monitoring_service.export_sessions + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.kwargs['limit'] == 100000 + @pytest.mark.asyncio - async def test_export_feedback(self, quart_test_client): + async def test_export_feedback(self, quart_test_client, fake_monitoring_app): """GET export?type=feedback returns CSV.""" response = await quart_test_client.get( '/api/v1/monitoring/export?type=feedback', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 + + assert 'text/csv' in response.content_type + assert 'monitoring-feedback-' in response.headers['Content-Disposition'] + body = (await response.get_data()).decode('utf-8-sig') + records = list(csv.DictReader(io.StringIO(body))) + assert len(records) == 1 + assert records[0]['id'] == 'fb-1' + called = fake_monitoring_app.monitoring_service.export_feedback + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.kwargs['limit'] == 100000 diff --git a/tests/integration/api/test_pipelines.py b/tests/integration/api/test_pipelines.py index 9f84aafb9..db1ec1d54 100644 --- a/tests/integration/api/test_pipelines.py +++ b/tests/integration/api/test_pipelines.py @@ -128,10 +128,10 @@ def fake_pipeline_app(): 'config': {}, } ) - app.pipeline_service.create_pipeline = AsyncMock(return_value={'uuid': 'new-pipeline-uuid'}) + app.pipeline_service.create_pipeline = AsyncMock(return_value='new-pipeline-uuid') app.pipeline_service.update_pipeline = AsyncMock(return_value={}) app.pipeline_service.delete_pipeline = AsyncMock() - app.pipeline_service.copy_pipeline = AsyncMock(return_value={'uuid': 'copied-pipeline-uuid'}) + app.pipeline_service.copy_pipeline = AsyncMock(return_value='copied-pipeline-uuid') # Bot service app.bot_service = Mock() @@ -161,6 +161,11 @@ def fake_pipeline_app(): return app +@pytest.fixture(autouse=True) +def reset_service_calls(fake_pipeline_app): + fake_pipeline_app.pipeline_service.reset_mock() + + @pytest.fixture(scope='module') async def quart_test_client(fake_pipeline_app, http_controller_cls): """Create Quart test client (module scope to avoid route re-registration).""" @@ -179,7 +184,7 @@ class TestPipelineMetadataEndpoint: """Tests for /api/v1/pipelines/_/metadata endpoint.""" @pytest.mark.asyncio - async def test_get_pipeline_metadata_success(self, quart_test_client): + async def test_get_pipeline_metadata_success(self, quart_test_client, fake_pipeline_app): """GET /api/v1/pipelines/_/metadata returns metadata list.""" response = await quart_test_client.get( '/api/v1/pipelines/_/metadata', headers={'Authorization': 'Bearer test_token'} @@ -191,6 +196,13 @@ class TestPipelineMetadataEndpoint: assert 'data' in data assert isinstance(data['data'], dict) + called = fake_pipeline_app.pipeline_service.get_pipeline_metadata + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {} + assert data['data'] == {'configs': [{'name': 'trigger', 'stages': []}, {'name': 'ai', 'stages': []}]} + @pytest.mark.asyncio async def test_get_pipeline_metadata_requires_auth(self, quart_test_client): """Pipeline metadata endpoint requires authentication.""" @@ -203,7 +215,7 @@ class TestPipelinesListEndpoint: """Tests for /api/v1/pipelines endpoint.""" @pytest.mark.asyncio - async def test_get_pipelines_success(self, quart_test_client): + async def test_get_pipelines_success(self, quart_test_client, fake_pipeline_app): """GET /api/v1/pipelines returns pipeline list.""" response = await quart_test_client.get('/api/v1/pipelines', headers={'Authorization': 'Bearer test_token'}) @@ -212,24 +224,46 @@ class TestPipelinesListEndpoint: assert data['code'] == 0 assert 'data' in data + called = fake_pipeline_app.pipeline_service.get_pipelines + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('created_at', 'DESC') + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['pipelines'] == [ + { + 'uuid': 'test-pipeline-uuid', + 'name': 'Test Pipeline', + 'description': 'Test description', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + 'is_default': False, + } + ] + @pytest.mark.asyncio - async def test_get_pipelines_with_sort_param(self, quart_test_client): + async def test_get_pipelines_with_sort_param(self, quart_test_client, fake_pipeline_app): """GET pipelines with sort parameter.""" response = await quart_test_client.get( - '/api/v1/pipelines?sort_by=created_at&sort_order=DESC', headers={'Authorization': 'Bearer test_token'} + '/api/v1/pipelines?sort_by=name&sort_order=ASC', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 data = await response.get_json() assert data['code'] == 0 + called = fake_pipeline_app.pipeline_service.get_pipelines + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('name', 'ASC') + assert called.await_args.kwargs == {'include_secret': True} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestPipelinesCRUDEndpoints: """Tests for pipeline CRUD operations.""" @pytest.mark.asyncio - async def test_get_single_pipeline_success(self, quart_test_client): + async def test_get_single_pipeline_success(self, quart_test_client, fake_pipeline_app): """GET /api/v1/pipelines/{uuid} returns pipeline.""" response = await quart_test_client.get( '/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'} @@ -240,8 +274,15 @@ class TestPipelinesCRUDEndpoints: assert data['code'] == 0 assert 'data' in data + called = fake_pipeline_app.pipeline_service.get_pipeline + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-pipeline-uuid',) + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['pipeline'] == {'uuid': 'test-pipeline-uuid', 'name': 'Test Pipeline', 'config': {}} + @pytest.mark.asyncio - async def test_create_pipeline_success(self, quart_test_client): + async def test_create_pipeline_success(self, quart_test_client, fake_pipeline_app): """POST /api/v1/pipelines creates new pipeline.""" response = await quart_test_client.post( '/api/v1/pipelines', @@ -254,6 +295,13 @@ class TestPipelinesCRUDEndpoints: assert data['code'] == 0 assert 'uuid' in data['data'] + called = fake_pipeline_app.pipeline_service.create_pipeline + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ({'name': 'New Pipeline', 'config': {}},) + assert called.await_args.kwargs == {'default': False} + assert data['data'] == {'uuid': 'new-pipeline-uuid'} + @pytest.mark.asyncio async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app): """POST /api/v1/pipelines explicitly creates a default pipeline.""" @@ -271,7 +319,7 @@ class TestPipelinesCRUDEndpoints: assert call.args[1]['is_default'] is True @pytest.mark.asyncio - async def test_update_pipeline_success(self, quart_test_client): + async def test_update_pipeline_success(self, quart_test_client, fake_pipeline_app): """PUT /api/v1/pipelines/{uuid} updates pipeline.""" response = await quart_test_client.put( '/api/v1/pipelines/test-pipeline-uuid', @@ -283,8 +331,14 @@ class TestPipelinesCRUDEndpoints: data = await response.get_json() assert data['code'] == 0 + called = fake_pipeline_app.pipeline_service.update_pipeline + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-pipeline-uuid', {'name': 'Updated Pipeline'}) + assert called.await_args.kwargs == {} + @pytest.mark.asyncio - async def test_delete_pipeline_success(self, quart_test_client): + async def test_delete_pipeline_success(self, quart_test_client, fake_pipeline_app): """DELETE /api/v1/pipelines/{uuid} deletes pipeline.""" response = await quart_test_client.delete( '/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'} @@ -294,8 +348,14 @@ class TestPipelinesCRUDEndpoints: data = await response.get_json() assert data['code'] == 0 + called = fake_pipeline_app.pipeline_service.delete_pipeline + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-pipeline-uuid',) + assert called.await_args.kwargs == {} + @pytest.mark.asyncio - async def test_copy_pipeline_success(self, quart_test_client): + async def test_copy_pipeline_success(self, quart_test_client, fake_pipeline_app): """POST /api/v1/pipelines/{uuid}/copy copies pipeline.""" response = await quart_test_client.post( '/api/v1/pipelines/test-pipeline-uuid/copy', headers={'Authorization': 'Bearer test_token'} @@ -306,14 +366,24 @@ class TestPipelinesCRUDEndpoints: assert data['code'] == 0 assert 'uuid' in data['data'] + called = fake_pipeline_app.pipeline_service.copy_pipeline + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-pipeline-uuid',) + assert called.await_args.kwargs == {} + assert data['data'] == {'uuid': 'copied-pipeline-uuid'} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestPipelineExtensionsEndpoint: """Tests for pipeline extensions.""" @pytest.mark.asyncio - async def test_get_extensions(self, quart_test_client): + async def test_get_extensions(self, quart_test_client, fake_pipeline_app): """GET /api/v1/pipelines/{uuid}/extensions.""" + fake_pipeline_app.plugin_connector.list_plugins.return_value = [{'name': 'extension-plugin'}] + fake_pipeline_app.mcp_service.get_mcp_servers.return_value = [{'uuid': 'extension-mcp'}] + fake_pipeline_app.skill_service.list_skills.return_value = [{'name': 'extension-skill'}] response = await quart_test_client.get( '/api/v1/pipelines/test-pipeline-uuid/extensions', headers={'Authorization': 'Bearer test_token'} ) @@ -323,6 +393,21 @@ class TestPipelineExtensionsEndpoint: data = await response.get_json() assert data['code'] == 0 + called = fake_pipeline_app.pipeline_service.get_pipeline + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-pipeline-uuid',) + assert called.await_args.kwargs == {} + assert data['data']['available_plugins'] == [{'name': 'extension-plugin'}] + assert data['data']['available_mcp_servers'] == [{'uuid': 'extension-mcp'}] + assert data['data']['available_skills'] == [{'name': 'extension-skill'}] + assert data['data']['bound_plugins'] == [] + assert data['data']['bound_mcp_servers'] == [] + assert data['data']['bound_skills'] == [] + assert data['data']['enable_all_plugins'] is False + assert data['data']['enable_all_mcp_servers'] is False + assert data['data']['enable_all_skills'] is False + @pytest.mark.asyncio async def test_get_extensions_redacts_available_plugin_secrets( self, @@ -377,7 +462,31 @@ class TestPipelineExtensionsEndpoint: @pytest.mark.usefixtures('mock_circular_import_chain') class TestPipelineDashboardWebSocket: @pytest.mark.asyncio - async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app): + async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app, monkeypatch): + import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as chat + + authenticated = False + original_authenticate = chat.WebSocketChatRouterGroup._authenticate_websocket + original_add = chat.ws_connection_manager.add_connection + proxy = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot + + async def authenticate(router): + nonlocal authenticated + result = await original_authenticate(router) + authenticated = True + return result + + async def get_proxy(*_args, **_kwargs): + assert authenticated, 'Runtime lookup must follow authentication' + return proxy.return_value + + async def add_connection(*args, **kwargs): + assert authenticated, 'Connection registration must follow authentication' + return await original_add(*args, **kwargs) + + monkeypatch.setattr(chat.WebSocketChatRouterGroup, '_authenticate_websocket', authenticate) + monkeypatch.setattr(proxy, 'side_effect', get_proxy) + monkeypatch.setattr(chat.ws_connection_manager, 'add_connection', add_connection) async with quart_test_client.websocket( '/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person', headers={'Origin': 'http://localhost'}, @@ -403,7 +512,13 @@ class TestPipelineDashboardWebSocket: fake_pipeline_app.platform_mgr.get_websocket_proxy_bot.assert_awaited() @pytest.mark.asyncio - async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client): + async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client, fake_pipeline_app, monkeypatch): + import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as chat + + proxy = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot + proxy.reset_mock() + add_connection = AsyncMock(wraps=chat.ws_connection_manager.add_connection) + monkeypatch.setattr(chat.ws_connection_manager, 'add_connection', add_connection) async with quart_test_client.websocket( '/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person', headers={'Origin': 'http://localhost'}, @@ -412,6 +527,9 @@ class TestPipelineDashboardWebSocket: response = json.loads(await websocket.receive()) assert response == {'type': 'error', 'message': 'Unauthorized'} + proxy.assert_not_awaited() + add_connection.assert_not_awaited() + @pytest.mark.asyncio async def test_websocket_rechecks_revocable_membership_before_each_message( self, diff --git a/tests/integration/api/test_providers.py b/tests/integration/api/test_providers.py index bedd1d871..cc01466b4 100644 --- a/tests/integration/api/test_providers.py +++ b/tests/integration/api/test_providers.py @@ -109,21 +109,23 @@ def fake_provider_app(): app.llm_model_service = Mock() app.llm_model_service.get_llm_models = AsyncMock(return_value=[{'uuid': 'test-model-uuid', 'name': 'gpt-4'}]) app.llm_model_service.get_llm_model = AsyncMock(return_value={'uuid': 'test-model-uuid', 'name': 'gpt-4'}) - app.llm_model_service.create_llm_model = AsyncMock(return_value={'uuid': 'new-model-uuid'}) + app.llm_model_service.create_llm_model = AsyncMock(return_value='new-model-uuid') app.llm_model_service.update_llm_model = AsyncMock(return_value={}) app.llm_model_service.delete_llm_model = AsyncMock() app.llm_model_service.test_llm_model = AsyncMock() # Embedding model service app.embedding_models_service = Mock() - app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[]) - app.embedding_models_service.create_embedding_model = AsyncMock(return_value={'uuid': 'new-embedding-uuid'}) + app.embedding_models_service.get_embedding_models = AsyncMock( + return_value=[{'uuid': 'embedding-model', 'name': 'Embedding'}] + ) + app.embedding_models_service.create_embedding_model = AsyncMock(return_value='new-embedding-uuid') app.embedding_models_service.test_embedding_model = AsyncMock() # Rerank model service app.rerank_models_service = Mock() - app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[]) - app.rerank_models_service.create_rerank_model = AsyncMock(return_value={'uuid': 'new-rerank-uuid'}) + app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[{'uuid': 'rerank-model', 'name': 'Rerank'}]) + app.rerank_models_service.create_rerank_model = AsyncMock(return_value='new-rerank-uuid') app.rerank_models_service.test_rerank_model = AsyncMock() # Model manager @@ -134,6 +136,14 @@ def fake_provider_app(): return app +@pytest.fixture(autouse=True) +def reset_service_calls(fake_provider_app): + fake_provider_app.provider_service.reset_mock() + fake_provider_app.llm_model_service.reset_mock() + fake_provider_app.embedding_models_service.reset_mock() + fake_provider_app.rerank_models_service.reset_mock() + + @pytest.fixture(scope='module') async def quart_test_client(fake_provider_app, http_controller_cls): """Create Quart test client (module scope to avoid route re-registration).""" @@ -206,7 +216,7 @@ class TestProviderEndpoints: assert data['data']['uuid'] == 'new-provider-uuid' @pytest.mark.asyncio - async def test_update_provider_success(self, quart_test_client): + async def test_update_provider_success(self, quart_test_client, fake_provider_app): """PUT /api/v1/provider/providers/{uuid} updates provider.""" response = await quart_test_client.put( '/api/v1/provider/providers/test-provider-uuid', @@ -218,8 +228,14 @@ class TestProviderEndpoints: data = await response.get_json() assert data['code'] == 0 + called = fake_provider_app.provider_service.update_provider + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-provider-uuid', {'name': 'Updated Provider'}) + assert called.await_args.kwargs == {} + @pytest.mark.asyncio - async def test_delete_provider_success(self, quart_test_client): + async def test_delete_provider_success(self, quart_test_client, fake_provider_app): """DELETE /api/v1/provider/providers/{uuid} deletes provider.""" response = await quart_test_client.delete( '/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'} @@ -227,8 +243,15 @@ class TestProviderEndpoints: assert response.status_code == 200 + called = fake_provider_app.provider_service.delete_provider + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-provider-uuid',) + assert called.await_args.kwargs == {} + assert (await response.get_json())['code'] == 0 + @pytest.mark.asyncio - async def test_get_provider_includes_model_counts(self, quart_test_client): + async def test_get_provider_includes_model_counts(self, quart_test_client, fake_provider_app): """GET provider response includes model counts.""" response = await quart_test_client.get( '/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'} @@ -243,13 +266,24 @@ class TestProviderEndpoints: assert 'embedding_count' in provider_data assert 'rerank_count' in provider_data + called = fake_provider_app.provider_service.get_provider_model_counts + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-provider-uuid',) + assert called.await_args.kwargs == {} + assert {key: provider_data[key] for key in ('llm_count', 'embedding_count', 'rerank_count')} == { + 'llm_count': 2, + 'embedding_count': 1, + 'rerank_count': 0, + } + @pytest.mark.usefixtures('mock_circular_import_chain') class TestModelEndpoints: """Tests for /api/v1/provider/models endpoints.""" @pytest.mark.asyncio - async def test_get_llm_models_success(self, quart_test_client): + async def test_get_llm_models_success(self, quart_test_client, fake_provider_app): """GET /api/v1/provider/models/llm returns model list.""" response = await quart_test_client.get( '/api/v1/provider/models/llm', headers={'Authorization': 'Bearer test_token'} @@ -260,8 +294,15 @@ class TestModelEndpoints: assert data['code'] == 0 assert 'data' in data + called = fake_provider_app.llm_model_service.get_llm_models + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['models'] == [{'uuid': 'test-model-uuid', 'name': 'gpt-4'}] + @pytest.mark.asyncio - async def test_get_single_llm_model_success(self, quart_test_client): + async def test_get_single_llm_model_success(self, quart_test_client, fake_provider_app): """GET /api/v1/provider/models/llm/{uuid} returns model.""" response = await quart_test_client.get( '/api/v1/provider/models/llm/test-model-uuid', headers={'Authorization': 'Bearer test_token'} @@ -271,8 +312,15 @@ class TestModelEndpoints: data = await response.get_json() assert data['code'] == 0 + called = fake_provider_app.llm_model_service.get_llm_model + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-model-uuid',) + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['model'] == {'uuid': 'test-model-uuid', 'name': 'gpt-4'} + @pytest.mark.asyncio - async def test_create_llm_model_success(self, quart_test_client): + async def test_create_llm_model_success(self, quart_test_client, fake_provider_app): """POST /api/v1/provider/models/llm creates new model.""" response = await quart_test_client.post( '/api/v1/provider/models/llm', @@ -285,8 +333,15 @@ class TestModelEndpoints: assert data['code'] == 0 assert 'uuid' in data['data'] + called = fake_provider_app.llm_model_service.create_llm_model + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ({'name': 'New Model', 'provider_uuid': 'test-provider-uuid'},) + assert called.await_args.kwargs == {} + assert data['data'] == {'uuid': 'new-model-uuid'} + @pytest.mark.asyncio - async def test_delete_llm_model_success(self, quart_test_client): + async def test_delete_llm_model_success(self, quart_test_client, fake_provider_app): """DELETE /api/v1/provider/models/llm/{uuid} deletes model.""" response = await quart_test_client.delete( '/api/v1/provider/models/llm/test-model-uuid', headers={'Authorization': 'Bearer test_token'} @@ -294,6 +349,13 @@ class TestModelEndpoints: assert response.status_code == 200 + called = fake_provider_app.llm_model_service.delete_llm_model + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-model-uuid',) + assert called.await_args.kwargs == {} + assert (await response.get_json())['code'] == 0 + @pytest.mark.asyncio @pytest.mark.parametrize( ('service_name', 'method_name', 'path'), @@ -337,7 +399,7 @@ class TestEmbeddingModelEndpoints: """Tests for /api/v1/provider/models/embedding endpoints.""" @pytest.mark.asyncio - async def test_get_embedding_models_success(self, quart_test_client): + async def test_get_embedding_models_success(self, quart_test_client, fake_provider_app): """GET /api/v1/provider/models/embedding returns model list.""" response = await quart_test_client.get( '/api/v1/provider/models/embedding', headers={'Authorization': 'Bearer test_token'} @@ -348,8 +410,15 @@ class TestEmbeddingModelEndpoints: assert data['code'] == 0 assert 'models' in data['data'] + called = fake_provider_app.embedding_models_service.get_embedding_models + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['models'] == [{'uuid': 'embedding-model', 'name': 'Embedding'}] + @pytest.mark.asyncio - async def test_create_embedding_model_success(self, quart_test_client): + async def test_create_embedding_model_success(self, quart_test_client, fake_provider_app): """POST /api/v1/provider/models/embedding creates new model.""" response = await quart_test_client.post( '/api/v1/provider/models/embedding', @@ -362,13 +431,20 @@ class TestEmbeddingModelEndpoints: assert data['code'] == 0 assert 'uuid' in data['data'] + called = fake_provider_app.embedding_models_service.create_embedding_model + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ({'name': 'New Embedding Model', 'provider_uuid': 'test-provider-uuid'},) + assert called.await_args.kwargs == {} + assert data['data'] == {'uuid': 'new-embedding-uuid'} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestRerankModelEndpoints: """Tests for /api/v1/provider/models/rerank endpoints.""" @pytest.mark.asyncio - async def test_get_rerank_models_success(self, quart_test_client): + async def test_get_rerank_models_success(self, quart_test_client, fake_provider_app): """GET /api/v1/provider/models/rerank returns model list.""" response = await quart_test_client.get( '/api/v1/provider/models/rerank', headers={'Authorization': 'Bearer test_token'} @@ -379,8 +455,15 @@ class TestRerankModelEndpoints: assert data['code'] == 0 assert 'models' in data['data'] + called = fake_provider_app.rerank_models_service.get_rerank_models + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['models'] == [{'uuid': 'rerank-model', 'name': 'Rerank'}] + @pytest.mark.asyncio - async def test_create_rerank_model_success(self, quart_test_client): + async def test_create_rerank_model_success(self, quart_test_client, fake_provider_app): """POST /api/v1/provider/models/rerank creates new model.""" response = await quart_test_client.post( '/api/v1/provider/models/rerank', @@ -392,3 +475,10 @@ class TestRerankModelEndpoints: data = await response.get_json() assert data['code'] == 0 assert 'uuid' in data['data'] + + called = fake_provider_app.rerank_models_service.create_rerank_model + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ({'name': 'New Rerank Model', 'provider_uuid': 'test-provider-uuid'},) + assert called.await_args.kwargs == {} + assert data['data'] == {'uuid': 'new-rerank-uuid'} diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index 864fa374f..14ca4579f 100644 --- a/tests/integration/api/test_smoke.py +++ b/tests/integration/api/test_smoke.py @@ -164,16 +164,6 @@ class TestHealthEndpoint: data = await response.get_json() assert data == {'code': 0, 'msg': 'ok', 'resources': {}} - @pytest.mark.asyncio - async def test_healthz_no_auth_required(self, quart_test_client): - """ - /healthz doesn't require authentication. - - Tests that AuthType.NONE endpoints work without headers. - """ - response = await quart_test_client.get('/healthz') - assert response.status_code == 200 - @pytest.mark.usefixtures('mock_circular_import_chain') class TestSystemEndpoint: @@ -241,33 +231,42 @@ class TestInvalidPayload: """Tests for error handling with invalid payloads.""" @pytest.mark.asyncio - async def test_missing_json_body(self, quart_test_client): - """ - POST endpoint without JSON body handles gracefully. - """ - # /api/v1/user/auth expects JSON with 'user' and 'password' + async def test_missing_json_body(self, quart_test_client, fake_api_app): + """A missing login body is rejected before authentication.""" response = await quart_test_client.post('/api/v1/user/auth') - # Should return error (500, 400, or 401) with stable JSON structure - assert response.status_code in (400, 500, 401) - data = await response.get_json() - - # Verify error response has expected structure - assert 'code' in data - assert 'msg' in data + assert response.status_code == 400 + assert await response.get_json() == {'code': -1, 'msg': 'Invalid request parameters'} + fake_api_app.user_service.authenticate.assert_not_awaited() @pytest.mark.asyncio - async def test_invalid_json_structure(self, quart_test_client): - """ - POST with wrong JSON structure returns stable error. - """ - response = await quart_test_client.post('/api/v1/user/auth', json={'wrong_field': 'value'}) + @pytest.mark.parametrize( + 'payload', + [ + {'wrong_field': 'value'}, + [], + {'user': None, 'password': 'secret'}, + {'user': 'owner@example.com', 'password': 42}, + ], + ) + async def test_invalid_json_structure(self, quart_test_client, fake_api_app, payload): + """Malformed credential fields never reach the authentication service.""" + response = await quart_test_client.post('/api/v1/user/auth', json=payload) - # Should return error with stable JSON structure - assert response.status_code in (400, 500, 401) - data = await response.get_json() - assert 'code' in data - assert 'msg' in data + assert response.status_code == 400 + assert await response.get_json() == {'code': -1, 'msg': 'Invalid request parameters'} + fake_api_app.user_service.authenticate.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize('password', ['', 'existing-password']) + async def test_string_credentials_reach_authentication(self, quart_test_client, fake_api_app, password): + response = await quart_test_client.post( + '/api/v1/user/auth', json={'user': 'owner@example.com', 'password': password} + ) + + assert response.status_code == 200 + assert (await response.get_json())['data']['token'] == 'fake_token' + fake_api_app.user_service.authenticate.assert_awaited_once_with('owner@example.com', password, None) @pytest.mark.usefixtures('mock_circular_import_chain') diff --git a/tests/integration/api/test_support_admin_launch.py b/tests/integration/api/test_support_admin_launch.py index dd578794f..85913eb41 100644 --- a/tests/integration/api/test_support_admin_launch.py +++ b/tests/integration/api/test_support_admin_launch.py @@ -405,7 +405,7 @@ async def test_support_admin_persisted_expiry_and_revocation_are_enforced(suppor async def test_support_admin_websocket_preserves_actor_and_revalidates(support_admin_api): - app, _client, _engine, private_key = support_admin_api + app, _client, engine, private_key = support_admin_api launch = await _issue_support_token(app, private_key) captured_contexts = [] @@ -414,7 +414,6 @@ async def test_support_admin_websocket_preserves_actor_and_revalidates(support_a del data captured_contexts.append(connection.execution_context) await connection.send_queue.put({'type': 'handled'}) - connection.is_active = False app.pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=SimpleNamespace(uuid='pipeline-1'))) app.platform_mgr = SimpleNamespace( @@ -439,8 +438,17 @@ async def test_support_admin_websocket_preserves_actor_and_revalidates(support_a await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'hi'}]})) handled = json.loads(await websocket.receive()) assert handled['type'] == 'handled' + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.update(SupportAdminTemporarySession) + .where(SupportAdminTemporarySession.grant_jti_hash == launch['grant_jti_hash']) + .values(revoked_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None)) + ) + await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'after revoke'}]})) + rejected = json.loads(await websocket.receive()) + assert rejected == {'type': 'error', 'message': 'Unauthorized'} - assert captured_contexts + assert len(captured_contexts) == 1 principal = captured_contexts[0].trigger_principal assert principal is not None assert principal.principal_type == PrincipalType.SUPPORT_ADMIN diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index ad49c83a6..f867e4a4f 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -234,39 +234,6 @@ async def test_redirect_rejects_invalid_callback_shape(space_oauth_api): assert all(payload['code'] == 1 for payload in payloads) -@pytest.mark.asyncio -async def test_explicit_server_side_webui_origin_supports_split_dev_server(space_oauth_api): - application, client = space_oauth_api - application.instance_config.data['api'] = {'webui_url': 'http://localhost:5173'} - - response = await client.get( - '/api/v1/user/space/authorize-url', - query_string={'redirect_uri': 'http://localhost:5173/auth/space/callback'}, - headers={'Origin': 'https://irrelevant.example'}, - ) - - assert response.status_code == 200 - assert (await response.get_json())['code'] == 0 - - -@pytest.mark.asyncio -async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api): - application, client = space_oauth_api - application.instance_config.data['api'] = { - 'webui_url': '', - 'webhook_prefix': 'https://langbot.example/base/path', - } - - response = await client.get( - '/api/v1/user/space/authorize-url', - query_string={'redirect_uri': 'https://langbot.example/auth/space/callback'}, - headers={'Host': 'attacker.example'}, - ) - - assert response.status_code == 200 - assert (await response.get_json())['code'] == 0 - - @pytest.mark.asyncio async def test_login_callback_requires_and_consumes_server_state(space_oauth_api): application, client = space_oauth_api @@ -423,6 +390,9 @@ async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oa ) assert response.status_code == 200 + payload = await response.get_json() + assert payload['code'] == 0 + assert payload['data']['token'] == 'space-login-token' application.directory_projection_service.reconcile_workspaces.assert_not_awaited() @@ -557,6 +527,7 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space_oauth_api): application, client = space_oauth_api application.user_service.consume_space_oauth_state.reset_mock() + application.user_service.consume_space_oauth_state_details.reset_mock() application.space_service.exchange_oauth_code.reset_mock() application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock()) @@ -578,6 +549,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space expected_workspace_uuid=WORKSPACE_UUID, ) application.user_service.consume_space_oauth_state.assert_not_awaited() + application.user_service.consume_space_oauth_state_details.assert_not_awaited() application.space_service.exchange_oauth_code.assert_not_awaited() @@ -591,7 +563,24 @@ async def test_direct_launch_reconciles_exact_workspace_before_resolving_access( status='active', ) application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account) - application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock()) + events = [] + + async def reconcile(_workspaces): + events.append('reconcile') + + async def get_account(_account_uuid): + events.append('account') + return projected_account + + access = application.workspace_collaboration_service.resolve_account_workspace.return_value + + async def resolve_access(*_args, **_kwargs): + events.append('access') + return access + + application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock(side_effect=reconcile)) + application.user_service.get_user_by_uuid.side_effect = get_account + application.workspace_collaboration_service.resolve_account_workspace.side_effect = resolve_access response = await client.post( '/api/v1/user/space/callback', @@ -605,3 +594,5 @@ async def test_direct_launch_reconciles_exact_workspace_before_resolving_access( assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,)) application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a') + + assert events == ['reconcile', 'account', 'access'] diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index dd2baf961..568606cc3 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -623,6 +623,12 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi ) assert expired_capabilities.status_code == 401 + await application.persistence_mgr.execute_async( + sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.uuid == created['uuid']).values(expires_at=None) + ) + valid_again = await client.get('/api/v1/system/context', headers={'X-API-Key': created['key']}) + assert valid_again.status_code == 200 + revoke_response = await client.delete( f'/api/v1/apikeys/{created["id"]}', headers=_auth(owner_token, workspace_uuid), diff --git a/tests/integration/persistence/test_codex_auth.py b/tests/integration/persistence/test_codex_auth.py index f0aae879a..5ce5662a0 100644 --- a/tests/integration/persistence/test_codex_auth.py +++ b/tests/integration/persistence/test_codex_auth.py @@ -298,12 +298,13 @@ async def test_device_replacement_expiry_and_idempotent_cancel(auth): async def test_device_accepts_issuer_iso_expiry(auth): from datetime import datetime, timezone - expires = datetime.fromtimestamp(time.time() + 600, timezone.utc).isoformat().replace('+00:00', 'Z') + expected_expiry = int(time.time()) + 600 + expires = datetime.fromtimestamp(expected_expiry, timezone.utc).isoformat().replace('+00:00', 'Z') auth._post = AsyncMock( return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE', 'expires_at': expires}) ) result = await auth.start(context(), 'p') - assert time.time() < result['expires_at'] < time.time() + 900 + assert result['expires_at'] == expected_expiry @pytest.mark.asyncio diff --git a/tests/integration/persistence/test_migration_branch_convergence.py b/tests/integration/persistence/test_migration_branch_convergence.py index 6020c9b37..58e4a5796 100644 --- a/tests/integration/persistence/test_migration_branch_convergence.py +++ b/tests/integration/persistence/test_migration_branch_convergence.py @@ -39,7 +39,7 @@ ACCOUNT = '41100000-0000-4000-8000-000000000002' NOW = datetime.datetime(2026, 9, 1) -@pytest.fixture(params=['sqlite', pytest.param('postgresql', marks=pytest.mark.slow)]) +@pytest.fixture(params=['sqlite', pytest.param('postgresql', marks=[pytest.mark.slow, pytest.mark.postgres])]) async def convergence_engine(request, tmp_path): if request.param == 'sqlite': engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "convergence.db"}') @@ -108,6 +108,18 @@ async def _seed_common(engine): base_url='https://fixture.invalid', api_keys=['fixture-key'], ) + await _insert( + conn, + 'llm_models', + uuid='model-1', + workspace_uuid=WORKSPACE, + name='Fixture model', + provider_uuid='provider-1', + abilities=['vision'], + extra_args={'sentinel': 'preserve-model'}, + prefered_ranking=7, + reasoning_config={'level': 'provider_default'}, + ) await _insert( conn, 'legacy_pipelines', @@ -261,6 +273,31 @@ async def _assert_merged_schema_and_data(engine, source): assert await conn.scalar(sa.text('SELECT password FROM users')) == 'hash' assert await conn.scalar(sa.text('SELECT COUNT(*) FROM workspace_memberships')) == 1 assert await conn.scalar(sa.text('SELECT name FROM model_providers')) == 'Fixture provider' + models = await conn.run_sync(lambda sync: sa.Table('llm_models', sa.MetaData(), autoload_with=sync)) + model = ( + await conn.execute( + sa.select( + models.c.name, + models.c.abilities, + models.c.extra_args, + models.c.prefered_ranking, + models.c.reasoning_config, + ) + ) + ).one() + assert tuple(model) == ( + 'Fixture model', + ['vision'], + {'sentinel': 'preserve-model'}, + 7, + {'level': 'provider_default'}, + ) + reasoning_column = await conn.run_sync( + lambda sync: next( + column for column in sa.inspect(sync).get_columns('llm_models') if column['name'] == 'reasoning_config' + ) + ) + assert reasoning_column['nullable'] is False assert await conn.scalar(sa.text('SELECT description FROM legacy_pipelines')) == 'preserve pipeline' assert ( await conn.execute(sa.text('SELECT message_content FROM monitoring_messages ORDER BY id')) @@ -306,8 +343,19 @@ async def _assert_merged_schema_and_data(engine, source): @pytest.mark.asyncio -@pytest.mark.parametrize('source', ['baseline', 'master', 'beta']) -async def test_historical_schema_and_populated_branch_upgrade(convergence_engine, source): +@pytest.mark.parametrize( + ('source', 'checkpoint'), + [ + ('baseline', None), + ('baseline', '0016_space_launch_replay'), + ('baseline', '0018_merge_workspace_heads'), + ('baseline', '0018_llm_reasoning_config'), + ('master', None), + ('beta', None), + ], +) +@pytest.mark.contract('migration_history') +async def test_historical_schema_and_populated_branch_upgrade(convergence_engine, source, checkpoint): engine = convergence_engine fixture = json.loads((FIXTURES / f'{source}_schema.json').read_text()) async with engine.begin() as conn: @@ -338,6 +386,9 @@ async def test_historical_schema_and_populated_branch_upgrade(convergence_engine await run_alembic_upgrade(engine, '0010_scope_resources') async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + if checkpoint is not None: + await run_alembic_upgrade(engine, checkpoint) + assert await get_alembic_current(engine) == checkpoint await run_alembic_upgrade(engine, 'head') await _assert_merged_schema_and_data(engine, source) await run_alembic_upgrade(engine, 'head') @@ -345,12 +396,37 @@ async def test_historical_schema_and_populated_branch_upgrade(convergence_engine @pytest.mark.asyncio +@pytest.mark.contract('migration_fresh_schema') async def test_empty_database_startup_schema_then_real_migrations(convergence_engine): engine = convergence_engine async with engine.begin() as conn: assert await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names()) == [] # This is the documented fresh-install contract: create_all precedes Alembic. await conn.run_sync(Base.metadata.create_all) + await conn.execute( + Base.metadata.tables['metadata'].insert().values(key='instance_uuid', value='migration-convergence-fresh') + ) + await conn.execute( + Base.metadata.tables['users'] + .insert() + .values( + uuid=ACCOUNT, user='fresh@example.com', normalized_email='fresh@example.com', password='fresh-password' + ) + ) + await run_alembic_upgrade(engine, 'head') + assert await get_alembic_current(engine) == get_alembic_head() + async with engine.connect() as conn: + tables = await conn.run_sync(lambda sync: set(sa.inspect(sync).get_table_names())) + assert { + 'users', + 'bots', + 'agents', + 'runner_state', + 'directory_projection_inbox', + 'passkey_credentials', + } <= tables + assert await conn.scalar(sa.text('SELECT password FROM users')) == 'fresh-password' + await run_alembic_upgrade(engine, 'head') assert await get_alembic_current(engine) == get_alembic_head() @@ -480,18 +556,6 @@ async def test_certification_artifact_digest_backfill_is_safe_and_keeps_legacy_d ) -@pytest.mark.asyncio -async def test_certification_artifact_digest_backfill_accepts_fresh_current_schema(convergence_engine): - engine = convergence_engine - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await run_alembic_upgrade(engine, 'head') - await run_alembic_upgrade(engine, 'head') - - assert await get_alembic_current(engine) == get_alembic_head() - - @pytest.mark.asyncio async def test_merge_only_downgrade_preserves_both_branch_schemas(convergence_engine): engine = convergence_engine diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index 9edfd5abb..602899195 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -122,39 +122,6 @@ class TestSQLiteMigrationBaseline: class TestSQLiteMigrationUpgrade: """Tests for upgrade to head workflow.""" - @pytest.mark.asyncio - async def test_upgrade_from_published_space_launch_head_to_merged_head(self, sqlite_engine): - """A database released at the production-only 0016 head must remain upgradable.""" - async with sqlite_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await run_alembic_stamp(sqlite_engine, '0016_space_launch_replay') - await run_alembic_upgrade(sqlite_engine, 'head') - - assert await get_alembic_current(sqlite_engine) == _get_script_head() - - @pytest.mark.asyncio - async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine): - """A database at the 4.11 development head must absorb later master migrations.""" - async with sqlite_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await run_alembic_stamp(sqlite_engine, '0018_merge_workspace_heads') - await run_alembic_upgrade(sqlite_engine, 'head') - - assert await get_alembic_current(sqlite_engine) == _get_script_head() - - @pytest.mark.asyncio - async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine): - """A database that already ran the feature migration must remain upgradable.""" - async with sqlite_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config') - await run_alembic_upgrade(sqlite_engine, 'head') - - assert await get_alembic_current(sqlite_engine) == _get_script_head() - @pytest.mark.asyncio async def test_upgrade_removes_agent_enabled_column(self, sqlite_engine): async with sqlite_engine.begin() as conn: @@ -171,62 +138,6 @@ class TestSQLiteMigrationUpgrade: assert 'enabled' not in columns - @pytest.mark.asyncio - async def test_upgrade_from_baseline_to_head(self, sqlite_engine): - """ - Upgrade from baseline to head applies all migrations. - - Workflow: - 1. Create tables - 2. Stamp baseline - 3. Upgrade to head - 4. Verify current revision is head - """ - # Create tables - async with sqlite_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - # Stamp baseline - await run_alembic_stamp(sqlite_engine, '0001_baseline') - - # Upgrade to head - await run_alembic_upgrade(sqlite_engine, 'head') - - # Verify revision - rev = await get_alembic_current(sqlite_engine) - assert rev is not None, 'Expected a revision after upgrade' - # Head should be the latest migration. Resolve the actual head from the - # Alembic script directory instead of hardcoding a revision number, so - # adding a new migration doesn't require editing this assertion. - assert rev == _get_script_head(), f'Expected head {_get_script_head()}, got {rev}' - - @pytest.mark.asyncio - async def test_upgrade_idempotent(self, sqlite_engine): - """ - Running upgrade to head multiple times is idempotent. - - Workflow: - 1. Upgrade to head - 2. Get revision - 3. Upgrade to head again - 4. Verify same revision - """ - # Create tables - async with sqlite_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - # Stamp and upgrade - await run_alembic_stamp(sqlite_engine, '0001_baseline') - await run_alembic_upgrade(sqlite_engine, 'head') - - rev1 = await get_alembic_current(sqlite_engine) - - # Upgrade again - should be idempotent - await run_alembic_upgrade(sqlite_engine, 'head') - - rev2 = await get_alembic_current(sqlite_engine) - assert rev2 == rev1, f'Expected {rev1}, got {rev2}' - @pytest.mark.asyncio async def test_upgrade_from_0012_adds_knowledge_base_embedding_dimension(self, sqlite_engine): """The PostgreSQL pgvector revision also evolves the OSS ORM schema.""" @@ -340,87 +251,14 @@ class TestSQLiteMigrationFreshDatabase: assert await get_alembic_current(sqlite_engine) == '0022_codex_credentials' @pytest.mark.asyncio - async def test_fresh_db_upgrade_from_scratch(self, tmp_path): - """ - Fresh database (no tables) can be upgraded directly to head. - - Workflow: - 1. Create fresh engine with new DB file - 2. Create tables - 3. Upgrade to head - 4. Verify revision - """ - # Use different DB file for fresh test - fresh_db_file = tmp_path / 'test_migrations_fresh.db' - fresh_url = f'sqlite+aiosqlite:///{fresh_db_file}' - fresh_engine = create_async_engine(fresh_url) - - # Create tables on fresh DB - async with fresh_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - # Upgrade to head directly (no baseline stamp) - await run_alembic_upgrade(fresh_engine, 'head') - - # Verify revision - rev = await get_alembic_current(fresh_engine) - assert rev is not None, 'Expected a revision on fresh DB' - - await fresh_engine.dispose() - - @pytest.mark.asyncio - async def test_fresh_db_without_create_all_behavior(self, tmp_path): - """ - Fresh database without create_all - test actual behavior. - - This tests what happens when migrations run on truly empty DB. - The behavior is determined by Alembic and migration scripts. - - EXPECTED: Either: - 1. Migration succeeds (if scripts handle empty DB) - 2. Migration fails with specific error (if scripts require tables) - - IMPORTANT: This test verifies the ACTUAL behavior, not accepting - any arbitrary failure with try-except pass. - """ - fresh_db_file = tmp_path / 'test_empty_migrations.db' - fresh_url = f'sqlite+aiosqlite:///{fresh_db_file}' - fresh_engine = create_async_engine(fresh_url) - - # Capture the actual behavior - actual_result = None - actual_error = None - - try: - await run_alembic_upgrade(fresh_engine, 'head') - rev = await get_alembic_current(fresh_engine) - actual_result = rev - except Exception as e: - actual_error = e - - await fresh_engine.dispose() - - # Verify specific behavior - one of two outcomes is expected - if actual_result is not None: - # Migration succeeded - verify revision exists - assert actual_result is not None, 'Revision should exist after successful migration' - else: - # Migration failed - verify the error type is known - # Alembic typically raises specific errors for missing tables - assert actual_error is not None, 'Error should be captured if migration failed' - # Log the error type for documentation (don't silently pass) - error_type = type(actual_error).__name__ - # Acceptable error types for empty DB scenarios - acceptable_errors = [ - 'OperationalError', # SQLite table not found - 'ProgrammingError', # SQLAlchemy errors - 'CommandError', # Alembic command errors - ] - assert error_type in acceptable_errors, ( - f'Unexpected error type: {error_type}. ' - f'This may indicate a regression in migration behavior. ' - f'Error: {actual_error}' - ) + async def test_fresh_db_without_create_all_behavior(self, sqlite_engine): + """Bare Alembic reaches head but relies on ORM bootstrap for core tables.""" + await run_alembic_upgrade(sqlite_engine, 'head') + assert await get_alembic_current(sqlite_engine) == _get_script_head() + async with sqlite_engine.connect() as conn: + tables = await conn.run_sync(lambda sync: set(sqlalchemy.inspect(sync).get_table_names())) + assert {'agents', 'runner_state', 'directory_projection_states', 'directory_projection_inbox'} <= tables + assert not {'users', 'bots', 'workspaces'} & tables class TestSQLiteMigrationGetCurrent: @@ -438,16 +276,3 @@ class TestSQLiteMigrationGetCurrent: # No stamp - should return None rev = await get_alembic_current(sqlite_engine) assert rev is None, f'Expected None for unstamped DB, got {rev}' - - @pytest.mark.asyncio - async def test_get_current_after_stamp_returns_revision(self, sqlite_engine): - """ - get_alembic_current returns correct revision after stamp. - """ - async with sqlite_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await run_alembic_stamp(sqlite_engine, '0001_baseline') - - rev = await get_alembic_current(sqlite_engine) - assert rev == '0001_baseline' diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index 1c0eb95c7..cb1bd807a 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -424,62 +424,6 @@ class TestPostgreSQLMigrationBaseline: class TestPostgreSQLMigrationUpgrade: """Tests for upgrade to head workflow on PostgreSQL.""" - @pytest.mark.asyncio - async def test_postgres_upgrade_from_baseline_to_head(self, postgres_engine, clean_tables, clean_alembic_version): - """ - Upgrade from baseline to head applies all migrations. - - Workflow: - 1. Create tables - 2. Stamp baseline - 3. Upgrade to head - 4. Verify current revision is head - """ - # Create tables - async with postgres_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - # Stamp baseline - await run_alembic_stamp(postgres_engine, '0001_baseline') - - # Upgrade to head - await run_alembic_upgrade(postgres_engine, 'head') - - # Verify revision - rev = await get_alembic_current(postgres_engine) - assert rev is not None, 'Expected a revision after upgrade' - # Head should be the latest migration. Resolve the actual head from the - # Alembic script directory instead of hardcoding a revision number, so - # adding a new migration doesn't require editing this assertion. - assert rev == _get_script_head(), f'Expected head {_get_script_head()}, got {rev}' - - @pytest.mark.asyncio - async def test_postgres_upgrade_idempotent(self, postgres_engine, clean_tables, clean_alembic_version): - """ - Running upgrade to head multiple times is idempotent. - - Workflow: - 1. Upgrade to head - 2. Get revision - 3. Upgrade to head again - 4. Verify same revision - """ - # Create tables - async with postgres_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - # Stamp and upgrade - await run_alembic_stamp(postgres_engine, '0001_baseline') - await run_alembic_upgrade(postgres_engine, 'head') - - rev1 = await get_alembic_current(postgres_engine) - - # Upgrade again - should be idempotent - await run_alembic_upgrade(postgres_engine, 'head') - - rev2 = await get_alembic_current(postgres_engine) - assert rev2 == rev1, f'Expected {rev1}, got {rev2}' - @pytest.mark.asyncio @pytest.mark.parametrize('settings_type', ['TEXT', 'JSON']) async def test_legacy_knowledge_restore_statement_accepts_historical_and_fresh_settings_types( @@ -550,21 +494,6 @@ class TestPostgreSQLMigrationGetCurrent: rev = await get_alembic_current(postgres_engine) assert rev is None, f'Expected None for unstamped DB, got {rev}' - @pytest.mark.asyncio - async def test_postgres_get_current_after_stamp_returns_revision( - self, postgres_engine, clean_tables, clean_alembic_version - ): - """ - get_alembic_current returns correct revision after stamp. - """ - async with postgres_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await run_alembic_stamp(postgres_engine, '0001_baseline') - - rev = await get_alembic_current(postgres_engine) - assert rev == '0001_baseline' - class TestPostgreSQLWorkspaceMigration: """Focused coverage for upgrading a pre-tenancy PostgreSQL instance.""" @@ -1070,7 +999,27 @@ class TestPostgreSQLTenantRuntime: await rag_manager.load_knowledge_bases_from_db() mcp_loader = MCPLoader(runtime_application) - mcp_loader.host_mcp_server = AsyncMock() + active_hosts = 0 + peak_active_hosts = 0 + peak_host_tasks = 0 + track_hosted_task = mcp_loader.track_hosted_task + + def observe_host_task(task, context): + nonlocal peak_host_tasks + track_hosted_task(task, context) + peak_host_tasks = max(peak_host_tasks, len(mcp_loader._hosted_mcp_tasks)) + + async def host_probe(_context, _config): + nonlocal active_hosts, peak_active_hosts + active_hosts += 1 + peak_active_hosts = max(peak_active_hosts, active_hosts) + try: + await asyncio.sleep(0) + finally: + active_hosts -= 1 + + mcp_loader.track_hosted_task = observe_host_task + mcp_loader.host_mcp_server = AsyncMock(side_effect=host_probe) await mcp_loader.load_mcp_servers_from_db() dispatch_tasks = tuple(mcp_loader._host_dispatch_tasks) if dispatch_tasks: @@ -1111,6 +1060,12 @@ class TestPostgreSQLTenantRuntime: assert len(pipeline_manager.pipelines) == workspace_count assert len(rag_manager.knowledge_bases) == workspace_count assert mcp_loader.host_mcp_server.await_count == workspace_count + assert 0 < peak_active_hosts <= min(workspace_count, 8) + assert 0 < peak_host_tasks <= min(workspace_count, 8) + assert active_hosts == 0 + logging.getLogger('postgres-capacity-runtime-test').info( + 'MCP startup peaks: active_hosts=%d hosted_tasks=%d', peak_active_hosts, peak_host_tasks + ) assert not mcp_loader._host_dispatch_tasks assert not mcp_loader._hosted_mcp_tasks assert len(plugin_handler.reconciled) == workspace_count diff --git a/tests/integration/persistence/test_pipeline_admission_postgres.py b/tests/integration/persistence/test_pipeline_admission_postgres.py index 6c95eae63..865a158eb 100644 --- a/tests/integration/persistence/test_pipeline_admission_postgres.py +++ b/tests/integration/persistence/test_pipeline_admission_postgres.py @@ -12,7 +12,11 @@ from langbot.pkg.persistence.pipeline_admission import lock_pipeline_admission from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction URL = os.environ.get('LANGBOT_ADMISSION_TEST_URL') -pytestmark = [pytest.mark.asyncio, pytest.mark.skipif(not URL, reason='disposable PostgreSQL URL required')] +pytestmark = [ + pytest.mark.postgres, + pytest.mark.asyncio, + pytest.mark.skipif(not URL, reason='disposable PostgreSQL URL required'), +] @pytest.fixture @@ -111,6 +115,7 @@ async def test_real_writer_waits_and_revalidates_after_transaction(env, outcome) assert (await writer)[0]['status'] == 'pending' +@pytest.mark.contract('admission_isolation') async def test_real_writer_first_blocks_migration_and_other_tenant_isolated(env): await write(env) async with env.pm.tenant_uow(base.WS) as uow: diff --git a/tests/integration/persistence/test_rag_document_identity.py b/tests/integration/persistence/test_rag_document_identity.py index 5a729dd87..21cc6d7dc 100644 --- a/tests/integration/persistence/test_rag_document_identity.py +++ b/tests/integration/persistence/test_rag_document_identity.py @@ -54,7 +54,7 @@ DOCUMENT_IDENTITY_REVISION = '0025_rag_document_identity' CONTEXT = ExecutionContext(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=5) -@pytest_asyncio.fixture(params=['sqlite', 'postgres']) +@pytest_asyncio.fixture(params=['sqlite', pytest.param('postgres', marks=pytest.mark.postgres)]) async def database(request, tmp_path): admin = None schema = 'ke_identity_' + uuid.uuid4().hex @@ -183,6 +183,7 @@ async def read_file_row(runtime, file_id='host-id'): @pytest.mark.parametrize( 'engine_id', ['dify-upstream', 'fastgpt-collection', 'ragflow-upstream', 'host-id', ' opaque ID '] ) +@pytest.mark.contract('rag_identity') async def test_ingest_persists_exact_engine_identity_and_restart_delete(runtime, engine_id): file = await seed(runtime) runtime.ap.plugin_connector.call_rag_ingest.return_value['document_id'] = engine_id diff --git a/tests/integration/persistence/test_rag_interrupted_ingestion.py b/tests/integration/persistence/test_rag_interrupted_ingestion.py index aa6b53dc6..8e6d77c26 100644 --- a/tests/integration/persistence/test_rag_interrupted_ingestion.py +++ b/tests/integration/persistence/test_rag_interrupted_ingestion.py @@ -25,6 +25,7 @@ def attach_task_manager(runtime): @pytest.mark.asyncio +@pytest.mark.contract('rag_recovery') async def test_cancelled_ingestion_retains_recovery_resources(runtime): """Local cancellation is interrupted observation, not remote quiescence.""" file = await seed(runtime) diff --git a/tests/integration/persistence/test_runner_timestamps_postgres.py b/tests/integration/persistence/test_runner_timestamps_postgres.py index 6cc988b7b..732b40490 100644 --- a/tests/integration/persistence/test_runner_timestamps_postgres.py +++ b/tests/integration/persistence/test_runner_timestamps_postgres.py @@ -25,7 +25,12 @@ from langbot.pkg.entity.persistence.base import Base from langbot.pkg.entity.persistence.event_log import EventLog from langbot.pkg.entity.persistence.transcript import Transcript -pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio] +pytestmark = [ + pytest.mark.postgres, + pytest.mark.integration, + pytest.mark.slow, + pytest.mark.asyncio, +] UTC = dt.timezone.utc NOW = dt.datetime(2026, 9, 22, 4, 5, 6, 123000, tzinfo=UTC) @@ -110,6 +115,7 @@ async def _legacy_run(engine, *, status='queued'): ) +@pytest.mark.contract('runner_timestamps') async def test_run_lifecycle_deadline_events_and_stats(pg_engine): store = run_ledger_store.RunLedgerStore(pg_engine) deadline = NOW + dt.timedelta(minutes=5) diff --git a/tests/integration/persistence/test_workspace_migration.py b/tests/integration/persistence/test_workspace_migration.py index 351b86cfd..3987ed524 100644 --- a/tests/integration/persistence/test_workspace_migration.py +++ b/tests/integration/persistence/test_workspace_migration.py @@ -166,10 +166,14 @@ async def test_workspace_upgrade_repairs_ownerless_existing_local_workspace(lega async with legacy_engine.connect() as conn: workspace = ( - await conn.execute( - sa.text("SELECT uuid, created_by_account_uuid FROM workspaces WHERE source = 'local'"), + ( + await conn.execute( + sa.text("SELECT uuid, created_by_account_uuid FROM workspaces WHERE source = 'local'"), + ) ) - ).mappings().one() + .mappings() + .one() + ) membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one() assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test') @@ -196,7 +200,7 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path): engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}') instance_id = 'instance_a711d9e4-0953-443f-a0e9-7dd50193a79f' old_workspace_uuid = '11111111-1111-4111-8111-111111111111' - canonical_uuid = workspace_uuid_from_instance_id(instance_id) + canonical_uuid = 'a711d9e4-0953-443f-a0e9-7dd50193a79f' schema = sa.MetaData() sa.Table( 'metadata', @@ -218,7 +222,15 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path): sa.Column('id', sa.Integer, primary_key=True), sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False), ) + sa.Table( + 'workspace_metadata', + schema, + sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False), + sa.Column('key', sa.String(255)), + sa.Column('value', sa.String(255)), + ) async with engine.begin() as conn: + await conn.execute(sa.text('PRAGMA foreign_keys = ON')) await conn.run_sync(schema.create_all) await conn.execute( sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id} @@ -233,6 +245,14 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path): sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'), {'uuid': old_workspace_uuid}, ) + await conn.execute( + sa.text("INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :value)"), + {'value': old_workspace_uuid}, + ) + await conn.execute( + sa.text("INSERT INTO workspace_metadata VALUES (:uuid, 'migration_probe', 'present')"), + {'uuid': old_workspace_uuid}, + ) await run_alembic_stamp(engine, '0016_support_admin_sessions') await run_alembic_upgrade(engine, 'head') @@ -240,6 +260,13 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path): async with engine.connect() as conn: assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid + assert ( + await conn.execute(sa.text('SELECT workspace_uuid FROM workspace_metadata')) + ).scalar_one() == canonical_uuid + assert ( + await conn.execute(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")) + ).scalar_one() == canonical_uuid + assert (await conn.execute(sa.text('PRAGMA foreign_key_check'))).all() == [] await engine.dispose() @@ -497,51 +524,6 @@ async def test_persistence_startup_preserves_legacy_workspace_membership_with_fo await manager.shutdown() -async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path): - engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}') - try: - await _create_legacy_schema(engine) - await run_alembic_stamp(engine, '0008_mcp_resource_prefs') - await run_alembic_upgrade(engine, '0016_support_admin_sessions') - - async with engine.begin() as conn: - old_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) - instance_uuid = await conn.scalar(sa.text("SELECT instance_uuid FROM workspaces WHERE source = 'local'")) - assert old_uuid - assert instance_uuid - await conn.execute( - sa.text( - 'INSERT INTO workspace_metadata (workspace_uuid, key, value) ' - "VALUES (:workspace_uuid, 'migration_probe', 'present')" - ), - {'workspace_uuid': old_uuid}, - ) - await conn.execute( - sa.text( - "INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) " - 'ON CONFLICT(key) DO UPDATE SET value = excluded.value' - ), - {'workspace_uuid': old_uuid}, - ) - - await run_alembic_upgrade(engine, 'head') - expected_uuid = workspace_uuid_from_instance_id(instance_uuid) - async with engine.connect() as conn: - assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid - assert ( - await conn.scalar( - sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'") - ) - == expected_uuid - ) - assert ( - await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")) - == expected_uuid - ) - finally: - await engine.dispose() - - async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch): engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}') try: diff --git a/tests/integration/pipeline/test_full_flow.py b/tests/integration/pipeline/test_full_flow.py index 6b2a0cf72..8ce63c4e3 100644 --- a/tests/integration/pipeline/test_full_flow.py +++ b/tests/integration/pipeline/test_full_flow.py @@ -353,6 +353,9 @@ class TestPreProcessorStage: # Check user_message content assert result.new_query.user_message is not None assert result.new_query.user_message.role == 'user' + assert [(part.type, part.text) for part in result.new_query.user_message.content] == [ + ('text', 'test message content') + ] @pytest.mark.usefixtures('load_pipeline_modules') @@ -370,6 +373,14 @@ class TestProcessorStage: # Create query query = text_query('hello') + from langbot_plugin.api.entities.builtin.provider.session import Session + + query.session = Session( + launcher_type=query.launcher_type, + launcher_id=query.launcher_id, + sender_id=query.sender_id, + using_conversation=await pipeline_app.sess_mgr.get_conversation(), + ) query.adapter = adapter query.pipeline_config = create_minimal_pipeline_config() query.resp_messages = [] @@ -390,9 +401,13 @@ class TestProcessorStage: # Collect results using helper results = await collect_processor_results(processor_stage, query, 'MessageProcessor') - assert len(results) >= 1 - # Check that resp_messages was populated - assert len(query.resp_messages) >= 1 + from langbot.pkg.pipeline import entities + + assert pipeline_app.agent_run_orchestrator.observed_queries == [query] + assert [result.result_type for result in results] == [entities.ResultType.CONTINUE] + assert [(message.role, message.content) for message in query.resp_messages] == [ + ('assistant', 'LANGBOT_FAKE_PONG') + ] @pytest.mark.asyncio async def test_processor_prevent_default_without_reply_interrupts(self, pipeline_app, fake_platform_adapter): diff --git a/tests/integration/vector/test_seekdb.py b/tests/integration/vector/test_seekdb.py index 668072aba..6cf919bde 100644 --- a/tests/integration/vector/test_seekdb.py +++ b/tests/integration/vector/test_seekdb.py @@ -14,16 +14,14 @@ import uuid import pytest -pytest.importorskip('pyseekdb') - -from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase - - -pytestmark = [pytest.mark.integration, pytest.mark.slow] +pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.seekdb] @pytest.fixture async def backend(tmp_path): + pytest.importorskip('pyseekdb') + from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase + app = SimpleNamespace( instance_config=SimpleNamespace( data={ @@ -51,6 +49,7 @@ async def backend(tmp_path): @pytest.mark.asyncio +@pytest.mark.contract('vector_round_trip') async def test_upsert_and_text_round_trip(backend) -> None: database, collection = backend original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文' @@ -80,6 +79,7 @@ async def test_upsert_and_text_round_trip(backend) -> None: @pytest.mark.asyncio +@pytest.mark.contract('hybrid_relevance') async def test_full_text_and_hybrid_results_keep_relevance_order(backend) -> None: database, collection = backend documents = [ diff --git a/tests/integration/vector/test_valkey_search.py b/tests/integration/vector/test_valkey_search.py index 4368a2bb0..ee4191122 100644 --- a/tests/integration/vector/test_valkey_search.py +++ b/tests/integration/vector/test_valkey_search.py @@ -169,6 +169,7 @@ async def test_full_text_search(backend): async def test_hybrid_filter_then_knn(backend): db, collection = backend ids, embeddings, metadatas, documents = _sample_docs() + documents = ['cats nearby', 'cats elsewhere', 'cats farther'] await db.add_embeddings(collection, ids, embeddings, metadatas, documents) result = await _poll_until( @@ -180,10 +181,10 @@ async def test_hybrid_filter_then_knn(backend): query_text='cats', filter={'file_id': 'fileA'}, ), - lambda r: len(r['ids'][0]) >= 1, + lambda r: len(r['ids'][0]) == 2, ) - # Only fileA docs (d1, d3) should be candidates. - assert set(result['ids'][0]).issubset({'d1', 'd3'}) + assert result['ids'][0] == ['d1', 'd3'] + assert [item['file_id'] for item in result['metadatas'][0]] == ['fileA', 'fileA'] @pytest.mark.asyncio @@ -191,16 +192,17 @@ async def test_vector_weight_not_honored(backend): """Passing different vector_weight values must NOT change ranking.""" db, collection = backend ids, embeddings, metadatas, documents = _sample_docs() + documents = ['cats nearby', 'cats elsewhere', 'cats farther'] await db.add_embeddings(collection, ids, embeddings, metadatas, documents) common = dict( collection=collection, query_embedding=[1.0, 0.0, 0.0, 0.0], k=3, search_type='hybrid', query_text='cats' ) - await _poll_until(lambda: db.search(**common), lambda r: len(r['ids'][0]) >= 1) + await _poll_until(lambda: db.search(**common), lambda r: len(r['ids'][0]) == 3) r_low = await db.search(**common, vector_weight=0.1) r_high = await db.search(**common, vector_weight=0.9) - assert r_low['ids'][0] == r_high['ids'][0] + assert r_low['ids'][0] == r_high['ids'][0] == ['d1', 'd3', 'd2'] @pytest.mark.asyncio @@ -257,6 +259,9 @@ async def test_delete_by_filter_returns_count(backend): deleted = await db.delete_by_filter(collection, filter={'file_id': 'fileA'}) assert deleted == 2 + items, total = await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda result: result[1] == 1) + assert total == 1 + assert [item['id'] for item in items] == ['d2'] @pytest.mark.asyncio @@ -273,6 +278,10 @@ async def test_list_by_filter_pagination(backend): page2, total = await db.list_by_filter(collection, limit=2, offset=2) assert total == 3 assert len(page2) == 1 + first_ids = {item['id'] for item in page1} + second_ids = {item['id'] for item in page2} + assert first_ids.isdisjoint(second_ids) + assert first_ids | second_ids == {'d1', 'd2', 'd3'} @pytest.mark.asyncio @@ -282,7 +291,11 @@ async def test_delete_collection(backend): await db.add_embeddings(collection, ids, embeddings, metadatas, documents) await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3) + client = await db._ensure_client() + keys = [f'kb:{collection}:{document_id}' for document_id in ids] + assert await client.exists(keys) == 3 await db.delete_collection(collection) + assert await client.exists(keys) == 0 # After dropping, search on a missing index returns empty. result = await db.search(collection, [1.0, 0.0, 0.0, 0.0], k=3, search_type='vector') diff --git a/tests/integration_tests/box/test_box_integration.py b/tests/integration_tests/box/test_box_integration.py index 9358515cd..02f5d4dd3 100644 --- a/tests/integration_tests/box/test_box_integration.py +++ b/tests/integration_tests/box/test_box_integration.py @@ -249,17 +249,68 @@ async def test_timeout_kills_command(box_client: ActionRPCBoxClient): @requires_socket @pytest.mark.asyncio async def test_offline_cannot_reach_network(box_client: ActionRPCBoxClient): - """With network=OFF the sandbox cannot reach the internet.""" - spec = BoxSpec( - cmd='wget -q -O /dev/null --timeout=3 http://1.1.1.1 2>&1; exit $?', - session_id='int-offline', - workdir='/tmp', - network=BoxNetworkMode.OFF, - image=_TEST_IMAGE, + """Only OFF blocks the same reachable private peer and installed HTTP client.""" + server = await box_client.execute( + BoxSpec( + cmd=r"""cat > /tmp/response.sh <<'HTTP' +#!/bin/sh +while IFS= read -r line; do + [ -z "$(printf '%s' "$line" | busybox tr -d '\r')" ] && break +done +printf 'HTTP/1.0 200 OK\r\nContent-Length: 19\r\n\r\nbox-network-control' +HTTP +chmod +x /tmp/response.sh +(while true; do busybox nc -l -p 8080 -e /bin/sh /tmp/response.sh; done >/tmp/http.log 2>&1 < /dev/null &) +sleep 1 && hostname -i""", + session_id='int-network-peer', + workdir='/tmp', + network=BoxNetworkMode.ON, + image=_TEST_IMAGE, + ) ) - result = await box_client.execute(spec) + assert server.status == BoxExecutionStatus.COMPLETED + assert server.exit_code == 0, server.stderr + import ipaddress - assert result.exit_code != 0 + peer = str(ipaddress.IPv4Address(server.stdout.strip().split()[0])) + assert not ipaddress.ip_address(peer).is_loopback + command = f'wget -q -O - --timeout=3 http://{peer}:8080/' + online = await box_client.execute( + BoxSpec( + cmd=command, + session_id='int-online', + workdir='/tmp', + network=BoxNetworkMode.ON, + image=_TEST_IMAGE, + ) + ) + assert online.status == BoxExecutionStatus.COMPLETED + assert online.exit_code == 0, online.stderr + assert online.stdout == 'box-network-control' + + offline = await box_client.execute( + BoxSpec( + cmd=command, + session_id='int-offline', + workdir='/tmp', + network=BoxNetworkMode.OFF, + image=_TEST_IMAGE, + ) + ) + assert offline.status == BoxExecutionStatus.COMPLETED + assert offline.exit_code != 0 + assert offline.stdout == '' + still_online = await box_client.execute( + BoxSpec( + cmd=command, + session_id='int-online', + workdir='/tmp', + network=BoxNetworkMode.ON, + image=_TEST_IMAGE, + ) + ) + assert still_online.exit_code == 0 + assert still_online.stdout == 'box-network-control' # ── 5. Backend unavailable ─────────────────────────────────────────── diff --git a/tests/test_cwe94_debug_exec.py b/tests/test_cwe94_debug_exec.py index b31085981..ba91931a6 100644 --- a/tests/test_cwe94_debug_exec.py +++ b/tests/test_cwe94_debug_exec.py @@ -12,12 +12,17 @@ This test verifies that: import ast import pathlib +import pytest + +pytestmark = pytest.mark.security + # Resolve project root (one level up from tests/) _PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent VULN_FILE = _PROJECT_ROOT / 'src' / 'langbot' / 'pkg' / 'api' / 'http' / 'controller' / 'groups' / 'system.py' +@pytest.mark.contract('debug_exec_absent') def test_no_exec_call_in_system_controller(): """Verify there is no exec() call in system.py that takes user input.""" with open(VULN_FILE, 'r') as f: @@ -38,6 +43,7 @@ def test_no_exec_call_in_system_controller(): ) +@pytest.mark.contract('debug_route_absent') def test_no_debug_exec_route(): """Verify the /debug/exec route is not registered.""" with open(VULN_FILE, 'r') as f: diff --git a/tests/unit_tests/agent/test_chat_handler.py b/tests/unit_tests/agent/test_chat_handler.py index 6eede3f28..10ac2109e 100644 --- a/tests/unit_tests/agent/test_chat_handler.py +++ b/tests/unit_tests/agent/test_chat_handler.py @@ -580,3 +580,4 @@ class TestChatHandlerAsyncBehavior: assert len(results) == 1 assert results[0].result_type == entities.ResultType.CONTINUE assert len(query.resp_messages) == 1 + assert query.resp_messages[0].content == 'Reply from plugin' diff --git a/tests/unit_tests/agent/test_event_first_protocol.py b/tests/unit_tests/agent/test_event_first_protocol.py index 29429d9ea..2721ff630 100644 --- a/tests/unit_tests/agent/test_event_first_protocol.py +++ b/tests/unit_tests/agent/test_event_first_protocol.py @@ -303,6 +303,10 @@ class TestRunnerContextProtocolV1: assert ctx.event is not None assert ctx.event.event_type == 'message.received' + from pydantic import ValidationError + + with pytest.raises(ValidationError, match='event'): + RunnerContext.model_validate(ctx.model_dump(exclude={'event'})) def test_sdk_context_has_no_history_message_fields(self): """RunnerContext should not expose inline history message fields.""" @@ -332,16 +336,6 @@ class TestRunnerContextProtocolV1: assert not hasattr(ctx, 'bootstrap') -class TestHostManagedHistoryNotInProtocol: - """Test that Host-managed history payloads are not in Protocol v1.""" - - def test_messages_not_in_sdk_context_top_level(self): - """RunnerContext should not expose top-level history messages.""" - ctx_fields = RunnerContext.model_fields.keys() - - assert 'messages' not in ctx_fields - - class TestSDKResultProtocolV1: """Test SDK RunnerResult for Protocol v1.""" @@ -355,6 +349,8 @@ class TestSDKResultProtocolV1: ) assert result.run_id == 'run_1' + with pytest.raises(TypeError, match='run_id'): + RunnerResult.message_completed(message=Message(role='assistant', content='Hello')) # Fixtures diff --git a/tests/unit_tests/agent/test_event_log_transcript.py b/tests/unit_tests/agent/test_event_log_transcript.py index 28540071c..c31468751 100644 --- a/tests/unit_tests/agent/test_event_log_transcript.py +++ b/tests/unit_tests/agent/test_event_log_transcript.py @@ -139,30 +139,31 @@ class TestTranscriptStore: """Test TranscriptStore operations.""" @pytest.mark.asyncio - async def test_append_transcript(self, mock_db_engine): + async def test_append_transcript(self): """Test appending a transcript item.""" - from unittest.mock import AsyncMock, MagicMock, patch + from sqlalchemy.ext.asyncio import create_async_engine + from langbot.pkg.entity.persistence.base import Base + from uuid import UUID - store = TranscriptStore(mock_db_engine) - - mock_session = AsyncMock() - mock_session.add = MagicMock() - mock_session.commit = AsyncMock() - - # Mock _get_next_seq - with patch.object(store, '_get_next_seq', return_value=1): - with patch.object(store, '_session_factory') as mock_factory: - mock_factory.return_value.__aenter__.return_value = mock_session - - transcript_id = await store.append_transcript( - transcript_id=None, # Auto-generate - event_id='evt_1', - conversation_id='conv_1', - role='user', - content='Hello', - ) - - assert transcript_id is not None + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + try: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + store = TranscriptStore(engine) + transcript_id = await store.append_transcript( + transcript_id=None, + event_id='evt_1', + conversation_id='conv_1', + role='user', + content='Hello', + ) + assert UUID(transcript_id).version == 4 + items, _, _, _ = await store.page_transcript('conv_1') + assert [(item['transcript_id'], item['event_id'], item['role'], item['content']) for item in items] == [ + (transcript_id, 'evt_1', 'user', 'Hello') + ] + finally: + await engine.dispose() class TestEventLogStoreRealSQLite: @@ -258,6 +259,7 @@ class TestEventLogStoreRealSQLite: async def test_get_latest_cursor(self, db_engine): """Test get_latest_cursor with real DB.""" store = EventLogStore(db_engine) + assert await store.get_latest_cursor('conv_cursor') is None # Append events for i in range(3): @@ -270,8 +272,12 @@ class TestEventLogStoreRealSQLite: # Get latest cursor cursor = await store.get_latest_cursor('conv_cursor') - assert cursor is not None - assert int(cursor) > 0 + assert cursor == '3' + await store.append_event( + event_id='other', event_type='message.received', source='platform', conversation_id='other' + ) + assert await store.get_latest_cursor('conv_cursor') == '3' + assert await store.get_latest_cursor('other') == '4' @pytest.mark.asyncio async def test_cleanup_events_older_than(self, db_engine): diff --git a/tests/unit_tests/agent/test_legacy_identity_boundary.py b/tests/unit_tests/agent/test_legacy_identity_boundary.py index 216ade894..d5347acfb 100644 --- a/tests/unit_tests/agent/test_legacy_identity_boundary.py +++ b/tests/unit_tests/agent/test_legacy_identity_boundary.py @@ -205,14 +205,43 @@ def official_plugin_root(): @pytest.mark.parametrize( 'runner,folder,method,error_name,local_identity', [ - ('DifyAgent', 'dify-agent', '_get_user_tag', 'DifyConfigError', 'person_Session_007'), - ('N8nAgent', 'n8n-agent', '_get_user_tag', 'N8nConfigError', 'person_Session_007'), - ('CozeAgent', 'coze-agent', '_get_user_id', 'CozeConfigError', 'person_launcher-123'), - ('TboxAgent', 'tbox-agent', '_get_user_id', 'TboxConfigError', 'bot-uuid-123'), + pytest.param( + 'DifyAgent', + 'dify-agent', + '_get_user_tag', + 'DifyConfigError', + 'person_Session_007', + marks=pytest.mark.contract('dify_identity'), + ), + pytest.param( + 'N8nAgent', + 'n8n-agent', + '_get_user_tag', + 'N8nConfigError', + 'person_Session_007', + marks=pytest.mark.contract('n8n_identity'), + ), + pytest.param( + 'CozeAgent', + 'coze-agent', + '_get_user_id', + 'CozeConfigError', + 'person_launcher-123', + marks=pytest.mark.contract('coze_identity'), + ), + pytest.param( + 'TboxAgent', + 'tbox-agent', + '_get_user_id', + 'TboxConfigError', + 'bot-uuid-123', + marks=pytest.mark.contract('tbox_identity'), + ), ], ) @pytest.mark.parametrize('missing', [False, True], ids=['trusted-identity', 'missing-identity']) @pytest.mark.parametrize('profile', ['dedicated', 'shared']) +@pytest.mark.plugin_identity async def test_real_plugin_identity_helper_with_host_payload( mock_query, monkeypatch, official_plugin_root, runner, folder, method, error_name, local_identity, missing, profile ): diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index 61802e15e..fa5fb18d1 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -789,6 +789,13 @@ async def test_orchestrator_applies_state_updates_and_suppresses_protocol_event( assert [message.content for message in messages] == ['state saved'] # State is persisted to the database via PersistentStateStore. + from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store + + event = QueryEntryAdapter.query_to_event(query) + config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID) + binding = AgentBindingResolver().resolve_one(event, [config]) + snapshot = await get_persistent_state_store(db_engine).build_snapshot_from_event(event, binding, descriptor) + assert snapshot['conversation'].get('external.conversation_id') == 'external_conv_123' @pytest.mark.asyncio diff --git a/tests/unit_tests/agent/test_registry.py b/tests/unit_tests/agent/test_registry.py index da7780622..025d23f74 100644 --- a/tests/unit_tests/agent/test_registry.py +++ b/tests/unit_tests/agent/test_registry.py @@ -124,6 +124,7 @@ class TestRegistryDiscovery: async def test_discover_caches_results(self): """Discovery should cache results.""" ap = FakeApplication() + ap.plugin_connector.list_runners = AsyncMock(wraps=ap.plugin_connector.list_runners) registry = RunnerRegistry(ap) # First discovery @@ -134,6 +135,11 @@ class TestRegistryDiscovery: assert registry._cache is not None assert len(runners1) == len(runners2) + ap.plugin_connector.list_runners.assert_awaited_once() + assert [runner.id for runner in runners2] == [ + 'plugin:langbot-team/LocalAgent/default', + 'plugin:alice/my-agent/custom', + ] @pytest.mark.asyncio async def test_discover_handles_plugin_disabled(self): diff --git a/tests/unit_tests/agent/test_result_normalizer.py b/tests/unit_tests/agent/test_result_normalizer.py index b4dbe0970..ca1bd0e39 100644 --- a/tests/unit_tests/agent/test_result_normalizer.py +++ b/tests/unit_tests/agent/test_result_normalizer.py @@ -156,6 +156,7 @@ class TestNormalizeRunCompleted: assert result is not None assert isinstance(result, provider_message.Message) + assert (result.role, result.content) == ('assistant', 'Final response') @pytest.mark.asyncio async def test_normalize_run_completed_without_message(self): diff --git a/tests/unit_tests/agent/test_run_ledger_api_auth.py b/tests/unit_tests/agent/test_run_ledger_api_auth.py index d3e6b8f5e..62d390326 100644 --- a/tests/unit_tests/agent/test_run_ledger_api_auth.py +++ b/tests/unit_tests/agent/test_run_ledger_api_auth.py @@ -175,6 +175,22 @@ async def _create_run( ) +@pytest.fixture +async def ledger_snapshot(db_engine): + await _create_run( + db_engine, run_id='workspace_guard', workspace_id='workspace_guard', plugin_identity='guard/runner' + ) + + async def snapshot(): + async with AsyncSession(db_engine) as session: + return [ + (await session.execute(sqlalchemy.select(*model.__table__.c).order_by(model.id))).mappings().all() + for model in (agent_run_model.AgentRun, agent_run_model.AgentRunEvent) + ] + + return snapshot + + @pytest.mark.asyncio async def test_run_get_returns_current_run(session_registry, db_engine): await _register_session(session_registry, available_apis={'run_get': True}) @@ -264,6 +280,7 @@ async def test_run_list_filters_same_scope_different_runner_owner(session_regist async def test_non_admin_target_run_actions_reject_same_scope_different_runner_owner( session_registry, db_engine, + ledger_snapshot, ): await _register_session( session_registry, @@ -351,10 +368,12 @@ async def test_non_admin_target_run_actions_reject_same_scope_different_runner_o ), ] + before = await ledger_snapshot() for action, payload in calls: result = await handler.actions[action](payload) assert result.code != 0, action assert 'not accessible' in result.message.lower() + assert await ledger_snapshot() == before @pytest.mark.asyncio @@ -1092,11 +1111,12 @@ async def test_run_append_result_uses_cas_and_redacts_metadata(session_registry, @pytest.mark.asyncio -async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine): +async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine, ledger_snapshot): await _register_session(session_registry, available_apis={'run_append_result': True}) await _create_run(db_engine) handler = _handler(db_engine) run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + before = await ledger_snapshot() result = await run_append_result( { @@ -1112,14 +1132,16 @@ async def test_run_append_result_rejects_side_effecting_result_type(session_regi assert result.code != 0 assert 'canonical runner result path' in result.message + assert await ledger_snapshot() == before @pytest.mark.asyncio -async def test_run_append_result_rejects_unknown_result_type(session_registry, db_engine): +async def test_run_append_result_rejects_unknown_result_type(session_registry, db_engine, ledger_snapshot): await _register_session(session_registry, available_apis={'run_append_result': True}) await _create_run(db_engine) handler = _handler(db_engine) run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + before = await ledger_snapshot() result = await run_append_result( { @@ -1135,14 +1157,16 @@ async def test_run_append_result_rejects_unknown_result_type(session_registry, d assert result.code != 0 assert 'unknown result type' in result.message + assert await ledger_snapshot() == before @pytest.mark.asyncio -async def test_run_append_result_validates_known_payloads(session_registry, db_engine): +async def test_run_append_result_validates_known_payloads(session_registry, db_engine, ledger_snapshot): await _register_session(session_registry, available_apis={'run_append_result': True}) await _create_run(db_engine) handler = _handler(db_engine) run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + before = await ledger_snapshot() result = await run_append_result( { @@ -1158,10 +1182,13 @@ async def test_run_append_result_validates_known_payloads(session_registry, db_e assert result.code != 0 assert 'invalid action.requested payload' in result.message + assert await ledger_snapshot() == before @pytest.mark.asyncio -async def test_run_append_and_finalize_claimed_target_require_active_claim(session_registry, db_engine): +async def test_run_append_and_finalize_claimed_target_require_active_claim( + session_registry, db_engine, ledger_snapshot +): await _register_session( session_registry, available_apis={'run_append_result': True, 'run_finalize': True}, @@ -1185,6 +1212,7 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi handler = _handler(db_engine) run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] run_finalize = handler.actions[PluginToRuntimeAction.RUN_FINALIZE.value] + before = await ledger_snapshot() missing_claim = await run_append_result( { @@ -1198,6 +1226,9 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi }, } ) + assert missing_claim.code != 0 + assert 'active claim ownership' in missing_claim.message + assert await ledger_snapshot() == before wrong_claim = await run_finalize( { 'run_id': 'run_1', @@ -1208,6 +1239,9 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi 'status': 'completed', } ) + assert wrong_claim.code != 0 + assert 'claim ownership is not active' in wrong_claim.message + assert await ledger_snapshot() == before appended = await run_append_result( { 'run_id': 'run_1', @@ -1233,10 +1267,6 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi } ) - assert missing_claim.code != 0 - assert 'active claim ownership' in missing_claim.message - assert wrong_claim.code != 0 - assert 'claim ownership is not active' in wrong_claim.message assert appended.code == 0 assert finalized.code == 0 assert finalized.data['status'] == 'completed' @@ -1471,7 +1501,7 @@ async def test_non_admin_run_claim_is_scoped_to_session_runner_and_conversation( @pytest.mark.asyncio -async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_registry, db_engine): +async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_registry, db_engine, ledger_snapshot): await _register_session( session_registry, available_apis={'run_renew_claim': True, 'run_release_claim': True}, @@ -1493,6 +1523,7 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg handler = _handler(db_engine) run_renew_claim = handler.actions[PluginToRuntimeAction.RUN_RENEW_CLAIM.value] run_release_claim = handler.actions[PluginToRuntimeAction.RUN_RELEASE_CLAIM.value] + before = await ledger_snapshot() renewed = await run_renew_claim( { @@ -1503,6 +1534,9 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg 'claim_token': claim['claim_token'], } ) + assert renewed.code != 0 + assert 'not accessible' in renewed.message.lower() + assert await ledger_snapshot() == before released = await run_release_claim( { 'run_id': 'run_1', @@ -1513,14 +1547,13 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg } ) - assert renewed.code != 0 - assert 'not accessible' in renewed.message.lower() assert released.code != 0 assert 'not accessible' in released.message.lower() + assert await ledger_snapshot() == before @pytest.mark.asyncio -async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_engine): +async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_engine, ledger_snapshot): await _register_session( session_registry, available_apis={'run_claim': True, 'run_release_claim': True}, @@ -1549,6 +1582,7 @@ async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_ } ) assert claimed.code == 0 + before = await ledger_snapshot() released = await run_release_claim( { @@ -1563,3 +1597,4 @@ async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_ assert released.code != 0 assert 'use run_finalize' in released.message + assert await ledger_snapshot() == before diff --git a/tests/unit_tests/agent/test_state_store.py b/tests/unit_tests/agent/test_state_store.py index a92c6a4af..8f9e758f1 100644 --- a/tests/unit_tests/agent/test_state_store.py +++ b/tests/unit_tests/agent/test_state_store.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import os import tempfile +from copy import deepcopy import pytest from sqlalchemy.ext.asyncio import create_async_engine @@ -132,6 +133,24 @@ class TestStateScopeHelpers: assert keys['subject'].startswith('subject:v2:') assert keys['runner'].startswith('runner:v2:') assert len(set(keys.values())) == len(keys) + for dimension, value in [ + ('workspace_id', 'ws_other'), + ('bot_id', 'bot_other'), + ]: + other = deepcopy(event) + setattr(other, dimension, value) + for scope in VALID_STATE_SCOPES: + assert build_state_scope_key(scope, other, binding, descriptor) != keys[scope] + for scope, dimension, value in [ + ('conversation', 'conversation_id', 'conv_other'), + ('conversation', 'thread_id', 'thread_other'), + ('actor', 'actor_id', 'user_other'), + ('subject', 'subject_id', 'msg_other'), + ]: + other = deepcopy(event) + owner = other.actor if scope == 'actor' else other.subject if scope == 'subject' else other + setattr(owner, dimension, value) + assert build_state_scope_key(scope, other, binding, descriptor) != keys[scope] def test_scope_key_missing_identity_returns_none(self): descriptor = make_descriptor() diff --git a/tests/unit_tests/api/service/test_agent_service.py b/tests/unit_tests/api/service/test_agent_service.py index d5fc567b8..f3916d829 100644 --- a/tests/unit_tests/api/service/test_agent_service.py +++ b/tests/unit_tests/api/service/test_agent_service.py @@ -868,6 +868,8 @@ async def test_event_processor_rejects_invalid_component_and_missing_parameters( service = AgentService(app) with pytest.raises(ValueError, match='Select an installed'): await service.create_agent(WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'invalid:a/b/c'}) + app.persistence_mgr.execute_async.assert_not_awaited() + app.runner_registry.get.assert_not_awaited() app.runner_registry = SimpleNamespace( get=AsyncMock( return_value=SimpleNamespace( @@ -882,6 +884,8 @@ async def test_event_processor_rejects_invalid_component_and_missing_parameters( await service.create_agent( WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'plugin:a/b/c', 'parameters': {}} ) + app.persistence_mgr.execute_async.assert_not_awaited() + app.runner_registry.get.assert_awaited_once_with(WORKSPACE_UUID, 'plugin:a/b/c') async def test_unavailable_event_processor_can_still_be_renamed(): diff --git a/tests/unit_tests/api/service/test_apikey_service.py b/tests/unit_tests/api/service/test_apikey_service.py index 160ffe430..85bc461c3 100644 --- a/tests/unit_tests/api/service/test_apikey_service.py +++ b/tests/unit_tests/api/service/test_apikey_service.py @@ -347,11 +347,6 @@ class TestApiKeyServiceVerifyApiKey: assert await service.verify_api_key('lbk_invalid_key') is False - async def test_verify_api_key_empty_string(self, api_key_context): - _application, service, _context, _engine = api_key_context - - assert await service.verify_api_key('') is False - async def test_verify_api_key_unknown_key(self, api_key_context): _application, service, _context, _engine = api_key_context diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index 96c6d24f0..574f28d7b 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -11,12 +11,15 @@ from __future__ import annotations import pytest from unittest.mock import AsyncMock, MagicMock, Mock, patch from types import SimpleNamespace +import hashlib +import hmac import json import sqlalchemy import uuid from langbot.pkg.api.http.service.bot import BotService from langbot.pkg.entity.persistence.bot import Bot +from langbot.pkg.workspace.errors import WorkspaceNotFoundError pytestmark = pytest.mark.asyncio @@ -182,6 +185,11 @@ class TestBotServiceGetBot: assert result is not None assert result['uuid'] == 'test-uuid' assert result['name'] == 'Found Bot' + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.whereclause.compare( + sqlalchemy.and_(Bot.uuid == 'test-uuid', Bot.workspace_uuid == WORKSPACE_UUID) + ) + ap.persistence_mgr.serialize_model.assert_called_once_with(Bot, bot, ['adapter_config']) async def test_get_bot_by_uuid_not_found(self): """Returns None when bot not found.""" @@ -361,6 +369,8 @@ class TestBotServiceCreateBot: # Execute & Verify with pytest.raises(ValueError, match='Maximum number of bots'): await service.create_bot(WORKSPACE_UUID, {'name': 'New Bot'}) + assert all(not call.args[0].is_insert for call in ap.persistence_mgr.execute_async.await_args_list) + ap.platform_mgr.load_bot.assert_not_awaited() async def test_create_bot_no_limit(self): """Creates bot without limit check when max_bots=-1.""" @@ -408,7 +418,22 @@ class TestBotServiceCreateBot: # Verify assert bot_uuid is not None - assert len(bot_uuid) == 36 # UUID format + assert uuid.UUID(bot_uuid).version == 4 + statement = ap.persistence_mgr.execute_async.await_args_list[0].args[0] + assert statement.is_insert + assert statement.table.name == Bot.__tablename__ + values = statement.compile().params + expected = { + 'uuid': bot_uuid, + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'New Bot', + 'adapter': 'telegram', + 'adapter_config': {}, + 'event_bindings': [], + 'plugin_processors': [], + } + assert {key: values.get(key) for key in expected} == expected + ap.platform_mgr.load_bot.assert_not_awaited() async def test_create_bot_scopes_insert_without_legacy_pipeline_fields(self): """Creates a Workspace-owned Bot without restoring removed pipeline columns.""" @@ -647,26 +672,28 @@ class TestBotServiceDeleteBot: await service.delete_bot(WORKSPACE_UUID, 'test-uuid') # Verify - ap.platform_mgr.remove_bot.assert_called_once_with(WORKSPACE_UUID, 'test-uuid') - ap.persistence_mgr.execute_async.assert_called_once() + ap.platform_mgr.remove_bot.assert_awaited_once_with(WORKSPACE_UUID, 'test-uuid') + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_delete + assert statement.table.name == Bot.__tablename__ + assert statement.whereclause.compare( + sqlalchemy.and_(Bot.uuid == 'test-uuid', Bot.workspace_uuid == WORKSPACE_UUID) + ) async def test_delete_bot_nonexistent_uuid(self): - """Delete operation completes even for nonexistent UUID.""" + """A missing persisted Bot is rejected before removing its runtime.""" # Setup ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() - ap.persistence_mgr.execute_async = AsyncMock() + ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=0)) ap.platform_mgr = SimpleNamespace() ap.platform_mgr.remove_bot = AsyncMock() service = BotService(ap) - service.get_bot = AsyncMock(return_value={'uuid': 'bot-uuid'}) - - # Execute - should not raise - await service.delete_bot(WORKSPACE_UUID, 'nonexistent-uuid') - - # Verify - both called regardless - ap.platform_mgr.remove_bot.assert_called_once() + with pytest.raises(WorkspaceNotFoundError, match='Bot not found'): + await service.delete_bot(WORKSPACE_UUID, 'nonexistent-uuid') + ap.platform_mgr.remove_bot.assert_not_awaited() class TestBotServiceListEventLogs: @@ -811,7 +838,9 @@ class TestBotServiceHttpBotInboundTest: assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}] headers = request.kwargs['headers'] assert headers['X-LB-Timestamp'] - assert headers['X-LB-Signature'].startswith('sha256=') + signing_input = headers['X-LB-Timestamp'].encode() + b'.' + request.kwargs['data'] + digest = hmac.new(b'test-secret', signing_input, hashlib.sha256).hexdigest() + assert headers['X-LB-Signature'] == f'sha256={digest}' async def test_rejects_non_http_bot(self): service = BotService(SimpleNamespace()) diff --git a/tests/unit_tests/api/service/test_cli_diagnostics.py b/tests/unit_tests/api/service/test_cli_diagnostics.py index 95b85e709..a78965f5b 100644 --- a/tests/unit_tests/api/service/test_cli_diagnostics.py +++ b/tests/unit_tests/api/service/test_cli_diagnostics.py @@ -236,6 +236,8 @@ async def test_extension_discovery_preserves_empty_bindings_and_redacts_plugins( ap.plugin_connector.require_workspace_context.assert_awaited_once_with(request_context()) +@pytest.mark.compiled_cli +@pytest.mark.contract('cli_http') async def test_compiled_cli_against_core_http(service, tmp_path): # noqa: F811 """Optional cross-repo check: real HTTP/SQLite/Pipeline, synthetic runtime providers.""" import json diff --git a/tests/unit_tests/api/service/test_cloud_managed_model_protection.py b/tests/unit_tests/api/service/test_cloud_managed_model_protection.py index 62ed06049..710cbf88c 100644 --- a/tests/unit_tests/api/service/test_cloud_managed_model_protection.py +++ b/tests/unit_tests/api/service/test_cloud_managed_model_protection.py @@ -73,7 +73,7 @@ async def test_all_model_types_reject_creation_under_managed_provider( with pytest.raises(ValueError, match='managed by Cloud'): await getattr(service, create_method)(WORKSPACE, model_data) - guard.assert_awaited_once() + guard.assert_awaited_once_with(application, WORKSPACE, PROVIDER) @pytest.mark.parametrize( @@ -109,4 +109,4 @@ async def test_all_model_types_reject_update_and_delete_for_managed_provider( with pytest.raises(ValueError, match='managed by Cloud'): await getattr(service, write_method)(*args) - guard.assert_awaited_once() + guard.assert_awaited_once_with(application, WORKSPACE, PROVIDER) diff --git a/tests/unit_tests/api/service/test_knowledge_service.py b/tests/unit_tests/api/service/test_knowledge_service.py index 908774892..1dc3496ca 100644 --- a/tests/unit_tests/api/service/test_knowledge_service.py +++ b/tests/unit_tests/api/service/test_knowledge_service.py @@ -390,6 +390,7 @@ class TestGetKnowledgeBase: result = await KnowledgeService(app).get_knowledge_base(CONTEXT, 'kb1') assert result == {'uuid': 'kb1', 'name': 'KB1'} + app.rag_mgr.get_knowledge_base_details.assert_awaited_once_with(CONTEXT, 'kb1') @pytest.mark.asyncio async def test_returns_none_when_not_found(self): @@ -555,7 +556,11 @@ class TestListParsers: result = await KnowledgeService(app).list_parsers(CONTEXT) - assert len(result) == 2 + assert result == [ + {'id': 'parser1', 'supported_mime_types': ['text/plain']}, + {'id': 'parser2', 'supported_mime_types': ['application/pdf']}, + ] + app.plugin_connector.list_parsers.assert_awaited_once_with() @pytest.mark.asyncio async def test_filters_by_mime_type(self): @@ -589,7 +594,8 @@ class TestGetEngineSchemas: 'author/engine', ) - assert 'properties' in result + assert result == {'properties': {'name': {'type': 'string'}}} + app.plugin_connector.get_rag_creation_schema.assert_awaited_once_with('author/engine') @pytest.mark.asyncio async def test_returns_retrieval_schema(self): @@ -601,7 +607,8 @@ class TestGetEngineSchemas: 'author/engine', ) - assert 'properties' in result + assert result == {'properties': {'top_k': {'type': 'integer'}}} + app.plugin_connector.get_rag_retrieval_schema.assert_awaited_once_with('author/engine') @pytest.mark.asyncio async def test_reports_schema_runtime_exception(self): diff --git a/tests/unit_tests/api/service/test_maintenance_service.py b/tests/unit_tests/api/service/test_maintenance_service.py index 3c12fabd6..77d6dbe90 100644 --- a/tests/unit_tests/api/service/test_maintenance_service.py +++ b/tests/unit_tests/api/service/test_maintenance_service.py @@ -14,8 +14,9 @@ Source: src/langbot/pkg/api/http/service/maintenance.py from __future__ import annotations import contextlib +import os import pytest -from unittest.mock import AsyncMock, Mock, patch, MagicMock +from unittest.mock import AsyncMock, Mock, patch, MagicMock, call from types import SimpleNamespace import datetime from pathlib import Path @@ -91,6 +92,8 @@ class TestMaintenanceServiceCleanupExpiredFiles: # Execute result = await service.cleanup_expired_files(TEST_CONTEXT) + service._cleanup_expired_uploaded_files.assert_awaited_once_with(TEST_CONTEXT, 7) + service._cleanup_expired_log_files.assert_called_once_with(3) # Verify - returns counts assert 'uploaded_files' in result @@ -128,6 +131,8 @@ class TestMaintenanceServiceCleanupExpiredFiles: # Execute result = await service.cleanup_expired_files(TEST_CONTEXT) + service._cleanup_expired_uploaded_files.assert_awaited_once_with(TEST_CONTEXT, 14) + service._cleanup_expired_log_files.assert_called_once_with(7) # Verify assert result['uploaded_files'] == 2 @@ -152,7 +157,10 @@ class TestMaintenanceServiceCleanupExpiredFiles: service = MaintenanceService(ap) # Mock the internal cleanup methods - service._cleanup_expired_uploaded_files = AsyncMock(return_value=1) + service._expired_s3_upload_candidates = AsyncMock(return_value=[{'key': 'scoped/expired-upload'}]) + protected = Mock() + protected.scalars.return_value.all.return_value = [] + ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=protected)) service._cleanup_expired_log_files = Mock(return_value=0) # NOT async # Execute @@ -161,6 +169,8 @@ class TestMaintenanceServiceCleanupExpiredFiles: # Verify assert result['uploaded_files'] == 1 assert result['log_files'] == 0 + service._expired_s3_upload_candidates.assert_awaited_once_with(TEST_CONTEXT, 7) + s3_provider.delete.assert_awaited_once_with('scoped/expired-upload') async def test_cleanup_expired_files_invalid_retention(self): """Uses default for invalid retention config.""" @@ -192,6 +202,8 @@ class TestMaintenanceServiceCleanupExpiredFiles: # Execute result = await service.cleanup_expired_files(TEST_CONTEXT) + service._cleanup_expired_uploaded_files.assert_awaited_once_with(TEST_CONTEXT, 7) + service._cleanup_expired_log_files.assert_called_once_with(3) # Verify - warning logged, defaults used assert ap.logger.warning.called @@ -280,7 +292,25 @@ class TestMaintenanceServiceGetStorageAnalysis: assert 'cleanup_policy' in result assert 'sections' in result assert 'database' in result - assert 'cleanup_candidates' in result + assert result['cleanup_policy'] == {'uploaded_file_retention_days': 7, 'log_retention_days': 3} + assert { + section['key']: (Path(section['path']).as_posix(), section['size_bytes'], section['file_count']) + for section in result['sections'] + } == { + 'database': ('data/langbot.db', 1000, 5), + 'logs': ('data/logs', 1000, 5), + 'storage': ('data/storage', 1000, 5), + 'vector_store': ('data/chroma', 1000, 5), + 'temp': ('data/temp', 1000, 5), + } + assert result['total_size_bytes'] == 5000 + assert result['database'] == { + 'type': 'sqlite', + 'monitoring_counts': {'messages': 10, 'errors': 0}, + 'binary_storage': {'count': 5, 'size_bytes': 500}, + } + assert result['cleanup_candidates'] == {'uploaded_files': [], 'log_files': []} + assert result['tasks'] == {'running': 0} async def test_get_storage_analysis_sections(self): """Returns all storage sections.""" @@ -462,8 +492,12 @@ class TestMaintenanceServiceGetStorageAnalysis: result = await service.get_storage_analysis(TEST_CONTEXT) # Verify - assert len(result['cleanup_candidates']['uploaded_files']) == 1 - assert len(result['cleanup_candidates']['log_files']) == 1 + assert result['cleanup_candidates'] == { + 'uploaded_files': [{'key': 'old_file', 'size_bytes': 100}], + 'log_files': [{'name': 'old_log', 'size_bytes': 50}], + } + service._expired_uploaded_candidates.assert_awaited_once_with(TEST_CONTEXT, 7) + service._expired_log_candidates.assert_called_once_with(3) class TestMaintenanceServiceMonitoringCounts: @@ -484,12 +518,9 @@ class TestMaintenanceServiceMonitoringCounts: result = await service._monitoring_counts(TEST_CONTEXT) # Verify - all table keys present - assert 'messages' in result - assert 'llm_calls' in result - assert 'embedding_calls' in result - assert 'errors' in result - assert 'sessions' in result - assert 'feedback' in result + assert result == dict.fromkeys( + ['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'errors', 'sessions', 'feedback'], 42 + ) async def test_monitoring_counts_zero_results(self): """Returns zero counts when tables empty.""" @@ -506,7 +537,9 @@ class TestMaintenanceServiceMonitoringCounts: result = await service._monitoring_counts(TEST_CONTEXT) # Verify - all zero - assert all(v == 0 for v in result.values()) + assert result == dict.fromkeys( + ['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'errors', 'sessions', 'feedback'], 0 + ) class TestMaintenanceServiceBinaryStorageStats: @@ -573,7 +606,7 @@ class TestMaintenanceServiceBinaryStorageStats: # Verify - warning logged, size_bytes None or 0 assert ap.logger.warning.called - assert result['count'] == 5 + assert result == {'count': 5, 'size_bytes': None} class TestMaintenanceServicePathSize: @@ -788,11 +821,17 @@ class TestMaintenanceServiceIsUploadedFileKey: service = MaintenanceService(ap) # Execute - simple filename without path - key = f'{ap.storage_mgr.scoped_prefix(TEST_CONTEXT, owner_type="upload")}uploaded_file.txt' + ap.storage_mgr.scoped_prefix = Mock(side_effect=lambda context, *, owner_type: f'scoped/{owner_type}/') + ap.storage_mgr.is_scoped_object_key = Mock(return_value=True) + key = 'scoped/upload/uploaded_file.txt' result = service._is_uploaded_file_key(TEST_CONTEXT, key) # Verify assert result is True + assert ap.storage_mgr.scoped_prefix.call_args_list == [ + call(TEST_CONTEXT, owner_type=owner) for owner in ('upload_image', 'upload_document', 'upload') + ] + ap.storage_mgr.is_scoped_object_key.assert_called_once_with(key, expected_owner_type='upload') def test_is_uploaded_file_key_with_path(self): """Returns False for key with path separator.""" @@ -907,7 +946,7 @@ class TestMaintenanceServiceExpiredLogCandidates: result = service._expired_log_candidates(3, include_paths=True) # Verify - path included - assert 'path' in result[0] + assert result[0]['path'] == '/data/logs/' + old_log_name class TestMaintenanceServiceExpiredLocalUploadCandidates: @@ -928,40 +967,38 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates: # Verify assert result == [] - def test_expired_local_upload_candidates_filters_uploaded(self): - """Only returns uploaded files matching pattern.""" - # Setup - ap = SimpleNamespace() - ap.logger = SimpleNamespace() - ap.storage_mgr = _scoped_storage_manager() + def test_expired_local_upload_candidates_filters_uploaded(self, tmp_path, monkeypatch): + """Only expired files below this Workspace's upload roots are candidates.""" + monkeypatch.chdir(tmp_path) + prefix = Mock(side_effect=lambda context, *, owner_type: f'scoped/{context.workspace_uuid}/{owner_type}/') + ap = SimpleNamespace(logger=SimpleNamespace(warning=Mock()), storage_mgr=SimpleNamespace(scoped_prefix=prefix)) + storage_root = Path('data/storage') + upload_root = storage_root / 'scoped/test-workspace/upload' + upload_root.mkdir(parents=True) + old = upload_root / 'valid_upload.txt' + old.write_bytes(b'x' * 100) + os.utime(old, (0, 0)) + recent = upload_root / 'recent.txt' + recent.write_text('keep') + (upload_root / 'directory').mkdir() + foreign = storage_root / 'scoped/other-workspace/upload/foreign.txt' + foreign.parent.mkdir(parents=True) + foreign.write_text('keep') + os.utime(foreign, (0, 0)) - service = MaintenanceService(ap) - # Create one file and one non-file entry under the scoped upload root. - mock_entry_valid = Mock(spec=Path) - mock_entry_valid.is_file = Mock(return_value=True) - mock_entry_valid.name = 'valid_upload.txt' - mock_stat = Mock() - mock_stat.st_size = 100 - mock_stat.st_mtime = 0 # Very old - mock_entry_valid.stat = Mock(return_value=mock_stat) - mock_entry_valid.relative_to = Mock(return_value=Path('scoped/valid_upload.txt')) + result = MaintenanceService(ap)._expired_local_upload_candidates(TEST_CONTEXT, 7) - mock_entry_plugin = Mock(spec=Path) - mock_entry_plugin.is_file = Mock(return_value=False) - mock_entry_plugin.name = 'plugin_config_test.json' - mock_stat2 = Mock() - mock_stat2.st_size = 200 - mock_stat2.st_mtime = 0 - mock_entry_plugin.stat = Mock(return_value=mock_stat2) - - with patch.object(Path, 'exists', return_value=True): - with patch.object(Path, 'rglob') as mock_rglob: - mock_rglob.return_value = [mock_entry_valid, mock_entry_plugin] - result = service._expired_local_upload_candidates(TEST_CONTEXT, 7) - - # Verify - only valid upload included - assert len(result) == 1 - assert result[0]['key'] == 'scoped/valid_upload.txt' + assert result == [ + { + 'key': 'scoped/test-workspace/upload/valid_upload.txt', + 'size_bytes': 100, + 'modified_at': '1970-01-01T00:00:00+00:00', + } + ] + assert prefix.call_args_list == [ + call(TEST_CONTEXT, owner_type=owner) for owner in ('upload_image', 'upload_document', 'upload') + ] + assert recent.read_text() == foreign.read_text() == 'keep' def test_expired_local_upload_candidates_includes_path(self): """Includes path when include_paths=True.""" @@ -991,7 +1028,7 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates: ) # Verify - path included - assert 'path' in result[0] + assert result[0]['path'] == '/data/storage/old_file.txt' def test_expired_local_upload_candidates_respects_run_limit(self): ap = SimpleNamespace( diff --git a/tests/unit_tests/api/service/test_mcp_service.py b/tests/unit_tests/api/service/test_mcp_service.py index 5cddde1e4..7ff7ef79f 100644 --- a/tests/unit_tests/api/service/test_mcp_service.py +++ b/tests/unit_tests/api/service/test_mcp_service.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio import copy +import sqlalchemy import pytest from unittest.mock import AsyncMock, Mock, MagicMock from types import SimpleNamespace @@ -97,6 +98,22 @@ def _create_mock_result(items: list = None, first_item=None, *, scalar_value=0, return result +def _write_statement(ap, operation): + statements = [ + call.args[0] + for call in ap.persistence_mgr.execute_async.await_args_list + if getattr(call.args[0], f'is_{operation}') + ] + assert len(statements) == 1 + statement = statements[0] + assert statement.table.name == MCPServer.__tablename__ + if operation != 'insert': + assert statement.whereclause.compare( + sqlalchemy.and_(MCPServer.uuid == 'test-uuid', MCPServer.workspace_uuid == _CONTEXT.workspace_uuid) + ) + return statement + + class TestMCPServiceGetRuntimeInfo: """Tests for get_runtime_info method.""" @@ -118,7 +135,8 @@ class TestMCPServiceGetRuntimeInfo: # Verify assert result is not None - assert result['status'] == 'running' + assert result == {'status': 'running', 'tools': 5} + ap.tool_mgr.mcp_tool_loader.get_session.assert_called_once_with(_CONTEXT, 'test-server') async def test_get_runtime_info_session_not_exists(self): """Returns None when session not exists.""" @@ -400,6 +418,8 @@ class TestMCPServiceCreateMCPServer: # Execute & Verify - 2 plugins + new server would exceed limit with pytest.raises(ValueError, match='Maximum number of extensions'): await service.create_mcp_server(_CONTEXT, {'name': 'New Server'}) + assert all(not call.args[0].is_insert for call in ap.persistence_mgr.execute_async.await_args_list) + ap.tool_mgr.mcp_tool_loader.get_session.assert_not_called() async def test_create_mcp_server_no_limit(self): """Creates MCP server without limit when max_extensions=-1.""" @@ -427,7 +447,11 @@ class TestMCPServiceCreateMCPServer: # Verify assert server_uuid is not None - assert len(server_uuid) == 36 # UUID format + assert uuid.UUID(server_uuid).version == 4 + values = _write_statement(ap, 'insert').compile().params + assert values['uuid'] == server_uuid + assert values['workspace_uuid'] == _CONTEXT.workspace_uuid + assert values['name'] == 'New Server' async def test_create_mcp_server_duplicate_name_raises(self): """Rejects duplicate MCP server names.""" @@ -485,7 +509,14 @@ class TestMCPServiceCreateMCPServer: await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': True}) # Verify - host_mcp_server was called - ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once() + values = _write_statement(ap, 'insert').compile().params + assert values['workspace_uuid'] == _CONTEXT.workspace_uuid + assert values['name'] == 'New Server' + assert values['enable'] is True + await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks) + ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with( + _CONTEXT, {'uuid': 'new-uuid', 'name': 'New Server', 'enable': True} + ) async def test_create_mcp_server_does_not_start_host_until_transaction_commits(self): """The Runtime must not observe a server row that can still roll back.""" @@ -536,25 +567,36 @@ class TestMCPServiceCreateMCPServer: ] async def test_create_mcp_server_disabled_no_load(self): - """Does not load server when disabled.""" + """Persists and passes disabled configuration to the runtime loader.""" # Setup ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() ap.instance_config = SimpleNamespace() ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}} - ap.tool_mgr = None + ap.tool_mgr = SimpleNamespace( + mcp_tool_loader=SimpleNamespace(host_mcp_server=AsyncMock(), _hosted_mcp_tasks=[]) + ) - mock_result = _create_mock_result([]) - ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid'}) + server = _create_mock_mcp_server(server_uuid='new-uuid', name='New Server', enable=False) + ap.persistence_mgr.execute_async = AsyncMock( + side_effect=[_create_mock_result(), _create_mock_result(), _create_mock_result(first_item=server)] + ) + ap.persistence_mgr.serialize_model = Mock( + return_value={'uuid': 'new-uuid', 'name': 'New Server', 'enable': False} + ) service = _service(ap) # Execute with enable=False server_uuid = await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': False}) - # Verify - no tool_mgr load attempt - assert server_uuid is not None + # Verify the persisted flag and the configuration passed to the loader + assert uuid.UUID(server_uuid).version == 4 + assert _write_statement(ap, 'insert').compile().params['enable'] is False + await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks) + ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with( + _CONTEXT, {'uuid': 'new-uuid', 'name': 'New Server', 'enable': False} + ) class TestMCPServiceGetMCPServerByName: @@ -585,6 +627,12 @@ class TestMCPServiceGetMCPServerByName: # Verify assert result is not None assert result['name'] == 'Found Server' + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.whereclause.compare( + sqlalchemy.and_(MCPServer.name == 'Found Server', MCPServer.workspace_uuid == _CONTEXT.workspace_uuid) + ) + ap.persistence_mgr.serialize_model.assert_called_once_with(MCPServer, server) + ap.tool_mgr.mcp_tool_loader.get_session.assert_called_once_with(_CONTEXT, 'Found Server') async def test_get_mcp_server_by_name_not_found(self): """Returns None when MCP server not found.""" @@ -647,7 +695,9 @@ class TestMCPServiceUpdateMCPServer: await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': False}) # Verify - server was removed - ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once() + values = _write_statement(ap, 'update').compile().params + assert values['enable'] is False + ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_awaited_once_with(_CONTEXT, 'Old Server') async def test_update_mcp_server_enable_disabled_server(self): """Loads server when enabling previously disabled server.""" @@ -687,7 +737,12 @@ class TestMCPServiceUpdateMCPServer: await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': True}) # Verify - server was loaded - ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once() + values = _write_statement(ap, 'update').compile().params + assert values['enable'] is True + await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks) + ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with( + _CONTEXT, {'uuid': 'test-uuid', 'name': 'Old Server', 'enable': True} + ) async def test_update_mcp_server_update_enabled_server(self): """Removes and reloads server when updating enabled server.""" @@ -725,7 +780,13 @@ class TestMCPServiceUpdateMCPServer: # Verify - remove and reload ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with(_CONTEXT, 'Old Server') - ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once() + values = _write_statement(ap, 'update').compile().params + assert values['enable'] is True + assert values.get('extra_args') == {'new': 'args'} + await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks) + ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with( + _CONTEXT, {'uuid': 'test-uuid', 'name': 'Old Server', 'enable': True} + ) async def test_update_mcp_server_no_tool_mgr(self): """Updates persistence without tool_mgr operations.""" @@ -763,7 +824,8 @@ class TestMCPServiceUpdateMCPServer: await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': False}) # Verify - persistence was called - assert ap.persistence_mgr.execute_async.call_count >= 2 + values = _write_statement(ap, 'update').compile().params + assert values['enable'] is False async def test_update_restores_existing_masked_secrets_and_preserves_explicit_changes(self): ap = SimpleNamespace() @@ -884,7 +946,7 @@ class TestMCPServiceDeleteMCPServer: # Verify ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with(_CONTEXT, 'Server to Delete') - ap.persistence_mgr.execute_async.assert_called() + _write_statement(ap, 'delete') async def test_delete_mcp_server_not_in_sessions(self): """Does not attempt remove if server not in sessions.""" @@ -923,7 +985,8 @@ class TestMCPServiceDeleteMCPServer: await service.delete_mcp_server(_CONTEXT, 'test-uuid') # Verify - remove not called (server not in sessions) - ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_not_called() + ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_not_awaited() + _write_statement(ap, 'delete') async def test_delete_mcp_server_nonexistent_uuid(self): """Delete operation completes even for nonexistent UUID.""" @@ -989,6 +1052,7 @@ class TestMCPServiceTestMCPServer: mock_session = MagicMock() mock_session.status = MCPSessionStatus.ERROR mock_session.start = AsyncMock() + mock_session.get_runtime_info_dict.return_value = {'status': 'connected', 'tools': 3} mock_session.refresh = AsyncMock() ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=mock_session) @@ -997,8 +1061,10 @@ class TestMCPServiceTestMCPServer: service = _service(ap) service._require_server = AsyncMock(return_value=(_CONTEXT, {'name': 'existing-server'})) - def create_user_task(coroutine, **_kwargs): - coroutine.close() + captured = {} + + def create_user_task(coroutine, **kwargs): + captured.update(coroutine=coroutine, context=kwargs['context']) return SimpleNamespace(id=123) ap.task_mgr.create_user_task = Mock(side_effect=create_user_task) @@ -1007,7 +1073,11 @@ class TestMCPServiceTestMCPServer: task_id = await service.test_mcp_server(_CONTEXT, 'existing-server', {}) # Verify - returns task ID + ap.tool_mgr.mcp_tool_loader.get_session.assert_called_once_with(_CONTEXT, 'existing-server') assert task_id == 123 + await captured['coroutine'] + mock_session.start.assert_awaited_once_with() + assert captured['context'].metadata['runtime_info'] == {'status': 'connected', 'tools': 3} @pytest.mark.parametrize('refresh_first', [False, True]) async def test_persisted_test_preserves_failure_details(self, refresh_first): @@ -1064,14 +1134,18 @@ class TestMCPServiceTestMCPServer: mock_session = MagicMock() mock_session.start = AsyncMock() + mock_session.shutdown = AsyncMock() + mock_session.get_runtime_info_dict.return_value = {'status': 'connected', 'tools': 3} ap.tool_mgr.mcp_tool_loader.load_mcp_server = AsyncMock(return_value=mock_session) ap.task_mgr = SimpleNamespace() service = _service(ap) - def create_user_task(coroutine, **_kwargs): - coroutine.close() + captured = {} + + def create_user_task(coroutine, **kwargs): + captured.update(coroutine=coroutine, context=kwargs['context']) return SimpleNamespace(id=456) ap.task_mgr.create_user_task = Mock(side_effect=create_user_task) @@ -1080,8 +1154,14 @@ class TestMCPServiceTestMCPServer: task_id = await service.test_mcp_server(_CONTEXT, '_', {'name': 'New Server'}) # Verify - load_mcp_server called - ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once() + ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_awaited_once_with( + _CONTEXT, {'name': 'New Server', 'workspace_uuid': _CONTEXT.workspace_uuid} + ) assert task_id == 456 + await captured['coroutine'] + mock_session.start.assert_awaited_once_with() + mock_session.shutdown.assert_awaited_once_with() + assert captured['context'].metadata['runtime_info'] == {'status': 'connected', 'tools': 3} async def test_transient_test_preserves_runtime_info_after_connection_failure(self): runtime_info = { diff --git a/tests/unit_tests/api/service/test_model_service.py b/tests/unit_tests/api/service/test_model_service.py index 91afa0916..c9824ca7b 100644 --- a/tests/unit_tests/api/service/test_model_service.py +++ b/tests/unit_tests/api/service/test_model_service.py @@ -14,6 +14,7 @@ Source: src/langbot/pkg/api/http/service/model.py from __future__ import annotations import pytest +import sqlalchemy from unittest.mock import AsyncMock, Mock from types import SimpleNamespace @@ -182,6 +183,42 @@ def _create_reasoning_runtime_provider(capabilities: dict) -> SimpleNamespace: ) +def _assert_created_model(ap, model_class, expected): + statements = [call.args[0] for call in ap.persistence_mgr.execute_async.await_args_list if call.args[0].is_insert] + assert len(statements) == 1 + statement = statements[0] + assert statement.table.name == model_class.__tablename__ + values = statement.compile().params + assert {key: values.get(key) for key in expected} == expected + kind = {LLMModel: 'llm', EmbeddingModel: 'embedding', RerankModel: 'rerank'}[model_class] + load = getattr(ap.model_mgr, f'load_{kind}_model_with_provider') + load.assert_awaited_once() + context, entity, provider = load.await_args.args + assert context == WORKSPACE_UUID + assert entity.uuid == expected['uuid'] + assert provider is ap.model_mgr.provider_dict[expected['provider_uuid']] + getattr(ap.model_mgr, f'cache_{kind}_model').assert_awaited_once_with(WORKSPACE_UUID, load.return_value) + + +def _assert_provider_query(ap, model_class, provider_uuid): + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_select + assert statement.whereclause.compare( + sqlalchemy.and_(model_class.provider_uuid == provider_uuid, model_class.workspace_uuid == WORKSPACE_UUID) + ) + + +def _assert_model_delete(ap, model_class, model_uuid): + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_delete + assert statement.table.name == model_class.__tablename__ + assert statement.whereclause.compare( + sqlalchemy.and_(model_class.uuid == model_uuid, model_class.workspace_uuid == WORKSPACE_UUID) + ) + + class TestParseProviderApiKeys: """Tests for _parse_provider_api_keys helper function.""" @@ -348,6 +385,8 @@ class TestLLMModelsServiceGetLLMModels: assert len(result) == 1 assert result[0]['name'] == 'Test LLM' assert result[0]['context_length'] == 128000 + assert result[0].get('provider', {}).get('uuid') == 'provider-uuid' + assert result[0].get('provider', {}).get('name') == 'Test Provider' async def test_get_llm_models_hide_secret_keys(self): """Hides secret API keys when include_secret=False.""" @@ -462,7 +501,9 @@ class TestLLMModelsServiceGetLLMModelsByProvider: mock_result = _create_mock_result([model1, model2]) ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'model-1', 'name': 'Model 1'}) + ap.persistence_mgr.serialize_model = Mock( + side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name} + ) service = LLMModelsService(ap) @@ -470,7 +511,8 @@ class TestLLMModelsServiceGetLLMModelsByProvider: result = await service.get_llm_models_by_provider(WORKSPACE_UUID, 'target-provider') # Verify - assert len(result) == 2 + assert [item['uuid'] for item in result] == ['model-1', 'model-2'] + _assert_provider_query(ap, LLMModel, 'target-provider') class TestLLMModelsServiceCreateLLMModel: @@ -507,6 +549,11 @@ class TestLLMModelsServiceCreateLLMModel: # Verify assert model_uuid is not None assert len(model_uuid) == 36 # UUID format + _assert_created_model( + ap, + LLMModel, + {'uuid': model_uuid, 'workspace_uuid': WORKSPACE_UUID, 'name': 'New LLM', 'provider_uuid': 'provider-uuid'}, + ) async def test_create_llm_model_preserve_uuid(self): """Creates LLM model preserving provided UUID.""" @@ -540,6 +587,16 @@ class TestLLMModelsServiceCreateLLMModel: # Verify assert model_uuid == 'preserved-uuid' + _assert_created_model( + ap, + LLMModel, + { + 'uuid': model_uuid, + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'Preserved UUID Model', + 'provider_uuid': 'provider-uuid', + }, + ) async def test_create_llm_model_persists_context_length_as_column(self): """Creates LLM model with context_length outside extra_args.""" @@ -574,6 +631,17 @@ class TestLLMModelsServiceCreateLLMModel: assert runtime_entity.context_length == 128000 assert runtime_entity.extra_args == {'temperature': 0.2} assert 'context_length' not in runtime_entity.extra_args + _assert_created_model( + ap, + LLMModel, + { + 'uuid': 'model-with-context', + 'workspace_uuid': WORKSPACE_UUID, + 'provider_uuid': 'provider-uuid', + 'context_length': 128000, + 'extra_args': {'temperature': 0.2}, + }, + ) async def test_create_llm_model_provider_not_found_raises_error(self): """Raises Exception when provider not found in runtime.""" @@ -639,7 +707,9 @@ class TestLLMModelsServiceCreateLLMModel: ) # Verify - provider_service was called and UUID generated - ap.provider_service.find_or_create_provider.assert_called_once() + ap.provider_service.find_or_create_provider.assert_awaited_once_with( + WORKSPACE_UUID, requester='openai', base_url='https://api.openai.com', api_keys=['key'] + ) assert result_uuid is not None async def test_create_llm_model_validates_explicit_reasoning_level(self): @@ -738,6 +808,11 @@ class TestLLMModelsServiceUpdateLLMModel: # Verify - remove and load called ap.model_mgr.remove_llm_model.assert_called_once_with(WORKSPACE_UUID, 'existing-uuid') + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_update + assert 'uuid' not in statement.compile().params + assert statement.compile().params.get('name') == 'Updated Name' async def test_update_llm_model_provider_not_found_raises_error(self): """Raises Exception when provider not found after update.""" @@ -795,6 +870,11 @@ class TestLLMModelsServiceUpdateLLMModel: assert runtime_entity.context_length == 64000 assert runtime_entity.extra_args == {'temperature': 0.4} assert 'context_length' not in runtime_entity.extra_args + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_update + assert statement.compile().params.get('context_length') == 64000 + assert statement.compile().params.get('extra_args') == {'temperature': 0.4} class TestLLMModelsServiceDeleteLLMModel: @@ -817,7 +897,8 @@ class TestLLMModelsServiceDeleteLLMModel: # Verify ap.persistence_mgr.execute_async.assert_called_once() - ap.model_mgr.remove_llm_model.assert_called_once_with(WORKSPACE_UUID, 'delete-uuid') + ap.model_mgr.remove_llm_model.assert_awaited_once_with(WORKSPACE_UUID, 'delete-uuid') + _assert_model_delete(ap, LLMModel, 'delete-uuid') class TestEmbeddingModelsServiceGetEmbeddingModels: @@ -880,7 +961,9 @@ class TestEmbeddingModelsServiceGetEmbeddingModels: ) # Verify - assert len(result) == 1 + assert [(item['uuid'], item['name']) for item in result] == [('embedding-uuid', 'Test Embedding')] + assert result[0].get('provider', {}).get('uuid') == 'provider-uuid' + assert result[0].get('provider', {}).get('name') == 'Test Provider' class TestEmbeddingModelsServiceGetEmbeddingModel: @@ -907,11 +990,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModel: ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) ap.persistence_mgr.serialize_model = Mock( - return_value={ - 'uuid': 'found-embedding', - 'name': 'Found Embedding', - 'provider': {'uuid': 'provider-uuid'}, - } + side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name} ) service = EmbeddingModelsService(ap) @@ -920,7 +999,11 @@ class TestEmbeddingModelsServiceGetEmbeddingModel: result = await service.get_embedding_model(WORKSPACE_UUID, 'found-embedding') # Verify - assert result is not None + assert result == { + 'uuid': 'found-embedding', + 'name': 'Test Embedding', + 'provider': {'uuid': 'provider-uuid', 'name': 'Test Provider'}, + } async def test_get_embedding_model_not_found(self): """Returns None when model not found.""" @@ -971,6 +1054,16 @@ class TestEmbeddingModelsServiceCreateEmbeddingModel: # Verify assert model_uuid is not None assert len(model_uuid) == 36 + _assert_created_model( + ap, + EmbeddingModel, + { + 'uuid': model_uuid, + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'New Embedding', + 'provider_uuid': 'provider-uuid', + }, + ) async def test_create_embedding_model_provider_not_found_raises(self): """Raises Exception when provider not found.""" @@ -1016,7 +1109,8 @@ class TestEmbeddingModelsServiceDeleteEmbeddingModel: await service.delete_embedding_model(WORKSPACE_UUID, 'delete-embedding-uuid') # Verify - ap.model_mgr.remove_embedding_model.assert_called_once() + ap.model_mgr.remove_embedding_model.assert_awaited_once_with(WORKSPACE_UUID, 'delete-embedding-uuid') + _assert_model_delete(ap, EmbeddingModel, 'delete-embedding-uuid') class TestRerankModelsServiceGetRerankModels: @@ -1078,7 +1172,9 @@ class TestRerankModelsServiceGetRerankModels: ) # Verify - assert len(result) == 1 + assert [(item['uuid'], item['name']) for item in result] == [('rerank-uuid', 'Test Rerank')] + assert result[0].get('provider', {}).get('uuid') == 'provider-uuid' + assert result[0].get('provider', {}).get('name') == 'Test Provider' class TestRerankModelsServiceGetRerankModel: @@ -1105,11 +1201,7 @@ class TestRerankModelsServiceGetRerankModel: ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) ap.persistence_mgr.serialize_model = Mock( - return_value={ - 'uuid': 'found-rerank', - 'name': 'Found Rerank', - 'provider': {'uuid': 'provider-uuid'}, - } + side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name} ) service = RerankModelsService(ap) @@ -1118,7 +1210,11 @@ class TestRerankModelsServiceGetRerankModel: result = await service.get_rerank_model(WORKSPACE_UUID, 'found-rerank') # Verify - assert result is not None + assert result == { + 'uuid': 'found-rerank', + 'name': 'Test Rerank', + 'provider': {'uuid': 'provider-uuid', 'name': 'Test Provider'}, + } async def test_get_rerank_model_not_found(self): """Returns None when model not found.""" @@ -1168,6 +1264,16 @@ class TestRerankModelsServiceCreateRerankModel: # Verify assert model_uuid is not None + _assert_created_model( + ap, + RerankModel, + { + 'uuid': model_uuid, + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'New Rerank', + 'provider_uuid': 'provider-uuid', + }, + ) async def test_create_rerank_model_provider_not_found_raises(self): """Raises Exception when provider not found.""" @@ -1213,7 +1319,8 @@ class TestRerankModelsServiceDeleteRerankModel: await service.delete_rerank_model(WORKSPACE_UUID, 'delete-rerank-uuid') # Verify - ap.model_mgr.remove_rerank_model.assert_called_once() + ap.model_mgr.remove_rerank_model.assert_awaited_once_with(WORKSPACE_UUID, 'delete-rerank-uuid') + _assert_model_delete(ap, RerankModel, 'delete-rerank-uuid') class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider: @@ -1230,7 +1337,9 @@ class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider: mock_result = _create_mock_result([model1, model2]) ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'emb-1', 'name': 'Embedding 1'}) + ap.persistence_mgr.serialize_model = Mock( + side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name} + ) service = EmbeddingModelsService(ap) @@ -1238,7 +1347,8 @@ class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider: result = await service.get_embedding_models_by_provider(WORKSPACE_UUID, 'provider-uuid') # Verify - assert len(result) == 2 + assert [item['uuid'] for item in result] == ['emb-1', 'emb-2'] + _assert_provider_query(ap, EmbeddingModel, 'provider-uuid') class TestRerankModelsServiceGetRerankModelsByProvider: @@ -1255,7 +1365,9 @@ class TestRerankModelsServiceGetRerankModelsByProvider: mock_result = _create_mock_result([model1, model2]) ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'rerank-1', 'name': 'Rerank 1'}) + ap.persistence_mgr.serialize_model = Mock( + side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name} + ) service = RerankModelsService(ap) @@ -1263,7 +1375,8 @@ class TestRerankModelsServiceGetRerankModelsByProvider: result = await service.get_rerank_models_by_provider(WORKSPACE_UUID, 'provider-uuid') # Verify - assert len(result) == 2 + assert [item['uuid'] for item in result] == ['rerank-1', 'rerank-2'] + _assert_provider_query(ap, RerankModel, 'provider-uuid') class TestValidateProviderSupports: diff --git a/tests/unit_tests/api/service/test_monitoring_sessions.py b/tests/unit_tests/api/service/test_monitoring_sessions.py index c10a3e98c..5750e466e 100644 --- a/tests/unit_tests/api/service/test_monitoring_sessions.py +++ b/tests/unit_tests/api/service/test_monitoring_sessions.py @@ -21,6 +21,8 @@ pytestmark = pytest.mark.asyncio @pytest.mark.asyncio(loop_scope='module') +@pytest.mark.postgres +@pytest.mark.contract('monitoring_sessions') async def test_postgres_upgrade_rls_and_concurrent_bot_counts(cloud_database): # noqa: F811 import asyncio import importlib diff --git a/tests/unit_tests/api/service/test_monitoring_tenancy.py b/tests/unit_tests/api/service/test_monitoring_tenancy.py index e745757c1..97280e15e 100644 --- a/tests/unit_tests/api/service/test_monitoring_tenancy.py +++ b/tests/unit_tests/api/service/test_monitoring_tenancy.py @@ -138,7 +138,8 @@ async def test_same_session_and_resource_ids_do_not_collide(service): assert (await service.get_message_details(context_a, message_b))['found'] is False -async def test_session_search_matches_user_id_or_name_within_workspace(service): +@pytest.mark.parametrize('bot_ids', [None, ['same-bot']]) +async def test_session_search_matches_user_id_or_name_within_workspace(service, bot_ids): context_a = _context(WORKSPACE_A) context_b = _context(WORKSPACE_B) fixtures = [ @@ -159,8 +160,8 @@ async def test_session_search_matches_user_id_or_name_within_workspace(service): user_name=user_name, ) - by_id, id_total = await service.get_sessions(context_a, user_query='customer-42') - by_name, name_total = await service.get_sessions(context_a, user_query='alice') + by_id, id_total = await service.get_sessions(context_a, bot_ids=bot_ids, user_query='customer-42') + by_name, name_total = await service.get_sessions(context_a, bot_ids=bot_ids, user_query='alice') assert id_total == 1 assert [session['session_id'] for session in by_id] == ['session-id-match'] diff --git a/tests/unit_tests/api/service/test_pipeline_migration.py b/tests/unit_tests/api/service/test_pipeline_migration.py index 901dfc608..362af7209 100644 --- a/tests/unit_tests/api/service/test_pipeline_migration.py +++ b/tests/unit_tests/api/service/test_pipeline_migration.py @@ -367,6 +367,9 @@ async def test_ordinary_update_requires_manual_migration_but_allows_metadata(env await service.update_pipeline(context(), 'one', {'name': 'renamed'}) configs, backups = await rows(env) assert configs['one'] == SOURCE and not backups + async with env.engine.connect() as conn: + name = (await conn.execute(sa.select(LegacyPipeline.name).where(LegacyPipeline.uuid == 'one'))).scalar_one() + assert name == 'renamed' @pytest.mark.asyncio @@ -777,7 +780,8 @@ async def test_all_mode_installs_missing_plugin_once_then_migrates_both(env): await conn.execute(sa.delete(PluginSetting)) async def install(source, info, task_context): - assert info['plugin_version'] == '1.0' + assert source == env.m.PluginInstallSource.MARKETPLACE + assert info == {'plugin_author': 'langbot-team', 'plugin_name': 'TestAgent', 'plugin_version': '1.0'} async with env.engine.begin() as conn: await conn.execute( sa.insert(PluginSetting).values( @@ -848,7 +852,14 @@ async def test_all_mode_has_no_fifty_pipeline_limit(env): ) response, metadata = await execute_all(env, install_plugins=False) assert len(response['pipeline_uuids']) == 53 + expected = {'one', 'two', *(f'extra-{i}' for i in range(51))} + assert {r['pipeline_uuid'] for r in metadata['results']} == expected + assert len(metadata['results']) == 53 assert all(r['state'] == 'migrated' for r in metadata['results']) + configs, backups = await rows(env) + assert len(backups) == 53 + assert all(configs[pipeline_uuid]['ai']['runner']['id'] == RID for pipeline_uuid in expected) + assert configs['foreign'] == SOURCE @pytest.mark.asyncio diff --git a/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py b/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py index b9a9801d9..e332d67bc 100644 --- a/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py +++ b/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py @@ -23,12 +23,6 @@ env = existing.env NATIVE = 'langbot.pkg.provider.runners.difysvapi' -@pytest.fixture(autouse=True) -async def interaction_schema(env): - async with env.engine.begin() as conn: - await conn.run_sync(lambda c: AgentInteraction.__table__.create(c, checkfirst=True)) - - async def add_request(env, status='pending', workspace=WS, pipeline='one'): async with env.engine.begin() as conn: await conn.execute( @@ -224,11 +218,3 @@ async def test_native_form_arriving_during_cas_rolls_back(env, monkeypatch): configs, snapshots = await rows(env) assert configs['one'] == SOURCE and not snapshots env.ap.pipeline_mgr.publish_pipeline.assert_not_called() - - -# Also exercise the unchanged regression assertions with this module's -# interaction-schema fixture. The original tests remain in their own module. - -for _name, _test in vars(existing).items(): - if _name.startswith('test_') and callable(_test): - globals()['test_existing_' + _name[5:]] = _test diff --git a/tests/unit_tests/api/service/test_pipeline_service.py b/tests/unit_tests/api/service/test_pipeline_service.py index dc8cb79d5..c1d65ede5 100644 --- a/tests/unit_tests/api/service/test_pipeline_service.py +++ b/tests/unit_tests/api/service/test_pipeline_service.py @@ -17,6 +17,7 @@ import pytest from unittest.mock import AsyncMock, Mock, patch, mock_open from types import SimpleNamespace import uuid +import sqlalchemy import json from langbot.pkg.api.http.service.pipeline import PipelineService, default_stage_order @@ -145,8 +146,12 @@ class TestPipelineServiceGetPipelines: ) # Verify - assert len(result) == 2 - ap.persistence_mgr.execute_async.assert_called_once() + assert result == [{'uuid': 'uuid-1', 'name': 'Pipeline 1'}, {'uuid': 'uuid-2', 'name': 'Pipeline 2'}] + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert len(statement._order_by_clauses) == 1 + assert next(iter(statement._order_by_clauses)).compare(LegacyPipeline.created_at.desc()) + assert statement.whereclause.compare(LegacyPipeline.workspace_uuid == WORKSPACE_UUID) async def test_get_pipelines_sort_by_updated_at_asc(self): """Returns pipelines sorted by updated_at ascending.""" @@ -164,7 +169,11 @@ class TestPipelineServiceGetPipelines: await service.get_pipelines(WORKSPACE_UUID, sort_by='updated_at', sort_order='ASC') # Verify - execute was called with sort parameters - ap.persistence_mgr.execute_async.assert_called_once() + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert len(statement._order_by_clauses) == 1 + assert next(iter(statement._order_by_clauses)).compare(LegacyPipeline.updated_at.asc()) + assert statement.whereclause.compare(LegacyPipeline.workspace_uuid == WORKSPACE_UUID) class TestPipelineServiceGetPipeline: @@ -271,7 +280,19 @@ class TestPipelineServiceCreatePipeline: # Verify assert bot_uuid is not None - assert len(bot_uuid) == 36 # UUID format + assert uuid.UUID(bot_uuid).version == 4 + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_insert + assert statement.table.name == LegacyPipeline.__tablename__ + values = statement.compile().params + assert values['uuid'] == bot_uuid + assert values['workspace_uuid'] == WORKSPACE_UUID + assert values['name'] == 'New Pipeline' + assert values['config'] == default_config + assert values['for_version'] == '1.0.0' + assert values['is_default'] is False + service.get_pipeline.assert_awaited_once_with(WORKSPACE_UUID, bot_uuid, include_secret=True) + ap.pipeline_mgr.load_pipeline.assert_awaited_once_with(WORKSPACE_UUID, service.get_pipeline.return_value) async def test_create_pipeline_as_default(self): """Creates pipeline with is_default=True.""" @@ -305,7 +326,10 @@ class TestPipelineServiceCreatePipeline: await service.create_pipeline(WORKSPACE_UUID, {'name': 'Default Pipeline'}, default=True) # Verify - execute was called - ap.persistence_mgr.execute_async.assert_called() + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_insert + assert statement.compile().params['is_default'] is True async def test_create_pipeline_sets_default_extensions_preferences(self): """Sets default extensions_preferences when not provided.""" @@ -515,21 +539,19 @@ class TestPipelineServiceDeletePipeline: ap.persistence_mgr.execute_async.assert_called_once() async def test_delete_pipeline_nonexistent_uuid(self): - """Delete operation completes even for nonexistent UUID.""" + """Missing persisted Pipelines do not remove runtime instances.""" # Setup ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() - ap.persistence_mgr.execute_async = AsyncMock() + ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=0)) ap.pipeline_mgr = SimpleNamespace() ap.pipeline_mgr.remove_pipeline = AsyncMock() service = PipelineService(ap) - # Execute - should not raise - await service.delete_pipeline(WORKSPACE_UUID, 'nonexistent-uuid') - - # Verify - ap.pipeline_mgr.remove_pipeline.assert_called_once() + with pytest.raises(WorkspaceNotFoundError, match='Pipeline not found'): + await service.delete_pipeline(WORKSPACE_UUID, 'nonexistent-uuid') + ap.pipeline_mgr.remove_pipeline.assert_not_awaited() class TestPipelineServiceCopyPipeline: @@ -627,7 +649,23 @@ class TestPipelineServiceCopyPipeline: # Verify assert new_uuid is not None - assert len(new_uuid) == 36 # UUID format + assert uuid.UUID(new_uuid).version == 4 + insert = ap.persistence_mgr.execute_async.await_args_list[1].args[0] + assert insert.is_insert + values = insert.compile().params + assert values['uuid'] == new_uuid + assert values['workspace_uuid'] == WORKSPACE_UUID + assert values['name'] == 'Original Pipeline (Copy)' + assert values['description'] == 'Original description' + assert values['stages'] == ['Stage1', 'Stage2'] + assert values['config'] == {'key': 'value'} + assert values['extensions_preferences']['enable_all_plugins'] is False + assert values['extensions_preferences']['plugins'] == [] + query = ap.persistence_mgr.execute_async.await_args_list[0].args[0] + assert query.whereclause.compare( + sqlalchemy.and_(LegacyPipeline.uuid == 'original-uuid', LegacyPipeline.workspace_uuid == WORKSPACE_UUID) + ) + service.get_pipeline.assert_awaited_once_with(WORKSPACE_UUID, new_uuid, include_secret=True) async def test_copy_pipeline_is_not_default(self): """Copy is never set as default.""" @@ -659,7 +697,20 @@ class TestPipelineServiceCopyPipeline: await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid') # Verify - pipeline_mgr.load_pipeline called (copy created) - ap.pipeline_mgr.load_pipeline.assert_called_once() + ap.pipeline_mgr.load_pipeline.assert_awaited_once_with(WORKSPACE_UUID, service.get_pipeline.return_value) + insert = ap.persistence_mgr.execute_async.await_args_list[1].args[0] + assert insert.is_insert + assert insert.compile().params['is_default'] is False + + +def _persisted_extensions(ap): + updates = [call.args[0] for call in ap.persistence_mgr.execute_async.await_args_list if call.args[0].is_update] + assert len(updates) == 1 + statement = updates[0] + assert statement.whereclause.compare( + sqlalchemy.and_(LegacyPipeline.uuid == 'test-uuid', LegacyPipeline.workspace_uuid == WORKSPACE_UUID) + ) + return statement.compile().params['extensions_preferences'] class TestPipelineServiceUpdatePipelineExtensions: @@ -731,7 +782,9 @@ class TestPipelineServiceUpdatePipelineExtensions: ) # Verify - ap.persistence_mgr.execute_async.assert_called() + persisted = _persisted_extensions(ap) + assert persisted['enable_all_plugins'] is False + assert persisted['plugins'] == [{'author': 'test', 'name': 'plugin-1'}] async def test_update_extensions_sets_mcp_servers(self): """Updates MCP servers in extensions_preferences.""" @@ -782,7 +835,9 @@ class TestPipelineServiceUpdatePipelineExtensions: ) # Verify - ap.persistence_mgr.execute_async.assert_called() + persisted = _persisted_extensions(ap) + assert persisted['enable_all_mcp_servers'] is False + assert persisted['mcp_servers'] == ['mcp-server-1'] async def test_update_extensions_none_mcp_servers_keeps_existing(self): """Does not modify mcp_servers when bound_mcp_servers is None.""" @@ -825,7 +880,7 @@ class TestPipelineServiceUpdatePipelineExtensions: await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[]) # Verify - persistence was called - ap.persistence_mgr.execute_async.assert_called() + assert _persisted_extensions(ap)['mcp_servers'] == ['existing-server'] async def test_update_extensions_preserves_mcp_resource_agent_read_when_omitted(self): """Does not reset mcp_resource_agent_read_enabled when omitted by older clients.""" @@ -863,10 +918,9 @@ class TestPipelineServiceUpdatePipelineExtensions: await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[]) - assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False - assert original_pipeline.extensions_preferences['mcp_resources'] == [ - {'server_uuid': 'srv-1', 'uri': 'file:///README.md'} - ] + persisted = _persisted_extensions(ap) + assert persisted['mcp_resource_agent_read_enabled'] is False + assert persisted['mcp_resources'] == [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}] class TestPipelineSecretRoundtrip: diff --git a/tests/unit_tests/api/service/test_provider_service.py b/tests/unit_tests/api/service/test_provider_service.py index b99509e5b..62612892d 100644 --- a/tests/unit_tests/api/service/test_provider_service.py +++ b/tests/unit_tests/api/service/test_provider_service.py @@ -14,12 +14,14 @@ Source: src/langbot/pkg/api/http/service/provider.py from __future__ import annotations import pytest +import sqlalchemy +from uuid import UUID from contextlib import nullcontext from unittest.mock import AsyncMock, Mock from types import SimpleNamespace from langbot.pkg.api.http.service.provider import ModelProviderService -from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel +from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel, EmbeddingModel, RerankModel from langbot.pkg.workspace.errors import WorkspaceNotFoundError @@ -300,7 +302,23 @@ class TestModelProviderServiceCreateProvider: # Verify - UUID is generated assert provider_uuid is not None - assert len(provider_uuid) == 36 # UUID format + assert UUID(provider_uuid).version == 4 + expected = { + 'uuid': provider_uuid, + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'New Provider', + 'requester': 'openai', + 'base_url': 'https://api.openai.com', + 'api_keys': ['key'], + } + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_insert + assert statement.table.name == ModelProvider.__tablename__ + values = statement.compile().params + assert {key: values.get(key) for key in expected} == expected + ap.model_mgr.load_provider.assert_awaited_once_with(WORKSPACE_UUID, expected) + ap.model_mgr.cache_provider.assert_awaited_once_with(WORKSPACE_UUID, runtime_provider) async def test_create_provider_loads_to_runtime(self): """Loads provider to runtime model_mgr.""" @@ -362,7 +380,15 @@ class TestModelProviderServiceUpdateProvider: ) # Verify - reload called - ap.model_mgr.reload_provider.assert_called_once_with(WORKSPACE_UUID, 'existing-uuid') + ap.model_mgr.reload_provider.assert_awaited_once_with(WORKSPACE_UUID, 'existing-uuid') + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_update + assert 'uuid' not in statement.compile().params + assert statement.compile().params.get('name') == 'Updated Name' + assert statement.whereclause.compare( + sqlalchemy.and_(ModelProvider.uuid == 'existing-uuid', ModelProvider.workspace_uuid == WORKSPACE_UUID) + ) async def test_update_provider_reloads_runtime(self): """Reloads provider in runtime after update.""" @@ -432,16 +458,20 @@ class TestModelProviderServiceGetProviderModelCounts: rerank_result = Mock() rerank_result.scalar = Mock(return_value=1) - call_count = 0 + results_by_table = { + LLMModel.__tablename__: (LLMModel, llm_result), + EmbeddingModel.__tablename__: (EmbeddingModel, embedding_result), + RerankModel.__tablename__: (RerankModel, rerank_result), + } async def mock_execute(query): - nonlocal call_count - call_count += 1 - if call_count == 1: - return llm_result - elif call_count == 2: - return embedding_result - return rerank_result + model_type, result = results_by_table[query.get_final_froms()[0].name] + assert query.whereclause.compare( + sqlalchemy.and_( + model_type.provider_uuid == 'provider-uuid', model_type.workspace_uuid == WORKSPACE_UUID + ) + ) + return result ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) @@ -602,7 +632,12 @@ class TestModelProviderServiceFindOrCreateProvider: ) # Verify - name should be parsed from URL (api.example.com) - ap.model_mgr.load_provider.assert_called_once() + ap.model_mgr.load_provider.assert_awaited_once() + assert ap.model_mgr.load_provider.await_args.args[0] == WORKSPACE_UUID + assert ap.model_mgr.load_provider.await_args.args[1]['name'] == 'api.example.com' + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_insert + assert statement.compile().params.get('name') == 'api.example.com' assert result_uuid is not None @@ -624,6 +659,16 @@ class TestModelProviderServiceUpdateSpaceModelProviderApiKeys: # Execute await service.update_space_model_provider_api_keys(WORKSPACE_UUID, 'space-api-key') + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_update + assert statement.compile().params.get('api_keys') == ['space-api-key'] + assert statement.whereclause.compare( + sqlalchemy.and_( + ModelProvider.uuid == '00000000-0000-0000-0000-000000000000', + ModelProvider.workspace_uuid == WORKSPACE_UUID, + ) + ) # Verify - update and reload called for Space provider UUID ap.model_mgr.reload_provider.assert_called_once_with( WORKSPACE_UUID, diff --git a/tests/unit_tests/api/service/test_session_context_reset.py b/tests/unit_tests/api/service/test_session_context_reset.py index ece96c7bd..43effa679 100644 --- a/tests/unit_tests/api/service/test_session_context_reset.py +++ b/tests/unit_tests/api/service/test_session_context_reset.py @@ -86,11 +86,19 @@ async def test_reset_preserves_logs_and_isolates_context(context_service): async with engine.connect() as conn: assert (await conn.execute(sa.select(sa.func.count()).select_from(Transcript))).scalar() == 2 assert (await conn.execute(sa.select(sa.func.count()).select_from(MonitoringSession))).scalar() == 1 - assert ( + remaining = ( await conn.execute( - sa.select(sa.func.count()).select_from(RunnerState).where(RunnerState.runner_id == 'runner') + sa.select( + RunnerState.workspace_id, RunnerState.bot_id, RunnerState.conversation_id, RunnerState.scope + ).where(RunnerState.runner_id == 'runner') ) - ).scalar() == 4 + ).all() + assert set(remaining) == { + ('w', 'b2', 'person_u', 'conversation'), + ('w2', 'b', 'person_u', 'conversation'), + ('w', 'b', 'person_other', 'conversation'), + ('w', 'b', 'person_u', 'actor'), + } page = await store.page_transcript('person_u', bot_id='b', workspace_id='w') assert not page[0] assert (await store.page_transcript('person_u', bot_id='b2', workspace_id='w'))[0] @@ -143,6 +151,6 @@ async def test_reset_starts_new_external_runner_conversation(context_service): ] == 'remote-old' await service.reset_session_context('w', 'b', 'person_u') snapshot = await store.build_snapshot_from_event(event, binding, descriptor) - assert snapshot['conversation']['external.conversation_id'] == await get_reset_generation( - engine, 'w', 'b', 'person_u' - ) + generation = await get_reset_generation(engine, 'w', 'b', 'person_u') + assert isinstance(generation, str) and generation and generation not in {'remote-old', 'person_u'} + assert snapshot['conversation']['external.conversation_id'] == generation diff --git a/tests/unit_tests/api/service/test_space_service.py b/tests/unit_tests/api/service/test_space_service.py index 8dd1543e7..043d8c6c1 100644 --- a/tests/unit_tests/api/service/test_space_service.py +++ b/tests/unit_tests/api/service/test_space_service.py @@ -15,6 +15,7 @@ from __future__ import annotations from collections import OrderedDict import json +import sqlalchemy from urllib.parse import parse_qs, urlsplit import pytest @@ -25,6 +26,7 @@ import time from langbot.pkg.api.http.service.space import SpaceService from langbot.pkg.entity.persistence.user import User +from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider from langbot.pkg.utils import constants @@ -157,6 +159,8 @@ class TestSpaceServiceGetUserByEmail: # Verify assert result is not None assert result.user == 'found@example.com' + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.whereclause.compare(User.user == 'found@example.com') async def test_get_user_by_email_not_found(self): """Returns None when user not found.""" @@ -478,6 +482,9 @@ class TestSpaceServiceRefreshToken: # Verify assert result['access_token'] == 'new_access_token' + mock_session_obj.post.assert_called_once_with( + 'https://space.langbot.app/api/v1/accounts/token/refresh', json={'refresh_token': 'old_refresh_token'} + ) async def test_refresh_token_api_error(self): """Raises ValueError on API error.""" @@ -662,6 +669,9 @@ class TestSpaceServiceGetUserInfoRaw: # Verify assert result['email'] == 'test@example.com' assert result['credits'] == 100 + mock_session_obj.get.assert_called_once_with( + 'https://space.langbot.app/api/v1/accounts/me', headers={'Authorization': 'Bearer access_token'} + ) async def test_get_user_info_raw_api_error(self): """Raises ValueError on API error.""" @@ -737,7 +747,8 @@ class TestSpaceServiceGetUserInfo: result = await service.get_user_info('test@example.com') # Verify - assert result['email'] == 'test@example.com' + assert result == {'email': 'test@example.com', 'credits': 100} + service.get_user_info_raw.assert_awaited_once_with('valid_token') class TestSpaceServiceGetModels: @@ -980,6 +991,20 @@ class TestSpaceServiceGetModelSelection: service.get_model_selection.assert_awaited_once_with('chat') model_mgr.sync_new_models_from_space.assert_awaited_once() assert persistence.execute_async.await_count == 2 + execution = model_mgr.sync_new_models_from_space.await_args.args[0] + assert (execution.instance_uuid, execution.workspace_uuid, execution.placement_generation) == ( + 'instance', + 'workspace', + 1, + ) + for call in persistence.execute_async.await_args_list: + assert call.args[0].whereclause.compare( + sqlalchemy.and_( + LLMModel.workspace_uuid == 'workspace', + ModelProvider.requester == 'space-chat-completions', + sqlalchemy.or_(LLMModel.uuid == 'best-upstream-uuid', LLMModel.name == 'best-chat-model'), + ) + ) class TestSpaceServiceCreditsCache: diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py index ae430d097..6d28212f4 100644 --- a/tests/unit_tests/api/service/test_user_service.py +++ b/tests/unit_tests/api/service/test_user_service.py @@ -13,6 +13,7 @@ Source: src/langbot/pkg/api/http/service/user.py from __future__ import annotations +import asyncio import pytest import jwt import datetime @@ -28,6 +29,7 @@ from langbot.pkg.api.http.service.user import ( UserService, ) from langbot.pkg.entity.persistence.user import AccountSource, AccountStatus, User +from langbot.pkg.entity.persistence.workspace import WorkspaceMembership, MembershipRole, MembershipStatus from langbot.pkg.entity.errors.account import ( AccountEmailMismatchError, SpaceAccountBindingRequiredError, @@ -269,6 +271,8 @@ class TestUserServiceGetUserByEmail: # Verify assert result is not None assert result.user == 'found@example.com' + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.whereclause.compare(User.normalized_email == 'found@example.com') async def test_get_user_by_email_not_found(self): """Returns None when user not found.""" @@ -327,6 +331,8 @@ class TestUserServiceGetUserBySpaceAccountUuid: # Verify assert result is not None assert result.space_account_uuid == 'space-uuid-123' + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.whereclause.compare(User.space_account_uuid == 'space-uuid-123') async def test_get_user_by_space_uuid_not_found(self): """Returns None when Space UUID not found.""" @@ -621,6 +627,16 @@ class TestUserServiceSetPassword: await service.set_password('haspass@example.com', 'new_password') +def _space_user_write(persistence, operation, credentials): + persistence.execute_async.assert_awaited_once() + statement = persistence.execute_async.await_args.args[0] + assert getattr(statement, f'is_{operation}') + assert statement.table.name == User.__tablename__ + values = statement.compile().params + assert {key: values.get(key) for key in credentials} == credentials + return statement, values + + class TestUserServiceCreateOrUpdateSpaceUser: """Tests for create_or_update_space_user method.""" @@ -657,7 +673,17 @@ class TestUserServiceCreateOrUpdateSpaceUser: ) # Verify - update was called and user returned - ap.persistence_mgr.execute_async.assert_called() + statement, values = _space_user_write( + ap.persistence_mgr, + 'update', + { + 'space_access_token': 'new_access_token', + 'space_refresh_token': 'new_refresh_token', + 'space_api_key': 'new_api_key', + }, + ) + assert statement.whereclause.compare(User.space_account_uuid == 'existing-space-uuid') + assert isinstance(values['space_access_token_expires_at'], datetime.datetime) assert updated_user.space_account_uuid == 'existing-space-uuid' async def test_cloud_login_updates_only_the_projected_space_account(self): @@ -690,7 +716,23 @@ class TestUserServiceCreateOrUpdateSpaceUser: ) assert result is projected - persistence.execute_async.assert_awaited_once() + statement, values = _space_user_write( + persistence, + 'update', + { + 'space_access_token': 'access-token', + 'space_refresh_token': 'refresh-token', + 'space_api_key': 'api-key', + }, + ) + assert statement.whereclause.compare( + sqlalchemy.and_( + User.uuid == 'projected-space-uuid', + User.space_account_uuid == 'projected-space-uuid', + User.source == AccountSource.CLOUD_PROJECTION.value, + ) + ) + assert isinstance(values['space_access_token_expires_at'], datetime.datetime) async def test_cloud_login_never_creates_an_unprojected_account(self): persistence = SimpleNamespace(execute_async=AsyncMock()) @@ -770,6 +812,20 @@ class TestUserServiceCreateOrUpdateSpaceUser: # Verify assert result.space_account_uuid == 'new-space-uuid' + _space_user_write( + ap.persistence_mgr, + 'insert', + { + 'user': 'newspace@example.com', + 'normalized_email': 'newspace@example.com', + 'password': '', + 'account_type': 'space', + 'space_account_uuid': 'new-space-uuid', + 'space_access_token': 'access_token', + 'space_refresh_token': 'refresh_token', + 'space_api_key': 'api_key', + }, + ) async def test_create_or_update_space_user_already_initialized_reports_unknown_space_email(self): """Unknown Space email is distinct from an existing local Account collision.""" @@ -914,6 +970,17 @@ class TestUserServiceCreateOrUpdateSpaceUser: # Verify assert result is not None assert result.space_account_uuid == 'noexpiry-uuid' + _space_user_write( + ap.persistence_mgr, + 'insert', + { + 'space_account_uuid': 'noexpiry-uuid', + 'space_access_token': 'token', + 'space_refresh_token': 'refresh', + 'space_api_key': 'key', + 'space_access_token_expires_at': None, + }, + ) async def test_bind_space_account_rejects_different_email(self): service = UserService(SimpleNamespace()) @@ -941,13 +1008,23 @@ class TestUserServiceCreateOrUpdateSpaceUser: async def test_get_workspace_owner_returns_user_object_from_core_connection_result(self): service = UserService(SimpleNamespace()) owner = _create_mock_user('owner@example.com', password='pw') - service.ap.persistence_mgr = SimpleNamespace( - current_session=lambda: SimpleNamespace(scalar=AsyncMock(return_value=owner)), - ) + session = SimpleNamespace(scalar=AsyncMock(return_value=owner)) + service.ap.persistence_mgr = SimpleNamespace(current_session=lambda: session) resolved = await service.get_workspace_owner('workspace-1') assert resolved is owner + session.scalar.assert_awaited_once() + statement = session.scalar.await_args.args[0] + assert statement.whereclause.compare( + sqlalchemy.and_( + WorkspaceMembership.workspace_uuid == 'workspace-1', + WorkspaceMembership.role == MembershipRole.OWNER.value, + WorkspaceMembership.status == MembershipStatus.ACTIVE.value, + User.status == AccountStatus.ACTIVE.value, + ) + ) + assert statement.get_final_froms()[0].onclause.compare(WorkspaceMembership.account_uuid == User.uuid) class TestUserServiceLoginCapabilities: @@ -980,4 +1057,5 @@ class TestUserServiceCreateUserLock: # Verify lock exists assert hasattr(service, '_create_user_lock') - assert service._create_user_lock is not None + assert isinstance(service._create_user_lock, asyncio.Lock) + assert not service._create_user_lock.locked() diff --git a/tests/unit_tests/api/service/test_webhook_service.py b/tests/unit_tests/api/service/test_webhook_service.py index 86046cdb1..df212363e 100644 --- a/tests/unit_tests/api/service/test_webhook_service.py +++ b/tests/unit_tests/api/service/test_webhook_service.py @@ -125,6 +125,24 @@ class TestWebhookServiceGetWebhooks: assert result[1]['name'] == 'Webhook 2' +def _assert_webhook_write(ap, operation, expected): + statements = [ + call.args[0] + for call in ap.persistence_mgr.execute_async.await_args_list + if getattr(call.args[0], f'is_{operation}') + ] + assert len(statements) == 1 + statement = statements[0] + assert statement.table.name == Webhook.__tablename__ + if operation == 'insert': + values = statement.compile().params + assert {key: values.get(key) for key in expected} == expected + else: + assert statement.whereclause.compare( + sqlalchemy.and_(Webhook.id == expected, Webhook.workspace_uuid == WORKSPACE_UUID) + ) + + class TestWebhookServiceCreateWebhook: """Tests for create_webhook method.""" @@ -187,6 +205,17 @@ class TestWebhookServiceCreateWebhook: assert result['url'] == 'http://new.example.com/webhook' assert result['description'] == 'New Description' assert result['enabled'] is True + _assert_webhook_write( + ap, + 'insert', + { + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'New Webhook', + 'url': 'http://new.example.com/webhook', + 'description': 'New Description', + 'enabled': True, + }, + ) async def test_create_webhook_defaults(self): """Creates webhook with default description and enabled.""" @@ -236,6 +265,17 @@ class TestWebhookServiceCreateWebhook: # Verify defaults assert result['description'] == '' assert result['enabled'] is True + _assert_webhook_write( + ap, + 'insert', + { + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'Minimal Webhook', + 'url': 'http://minimal.example.com', + 'description': '', + 'enabled': True, + }, + ) async def test_create_webhook_disabled(self): """Creates webhook with enabled=False.""" @@ -271,6 +311,7 @@ class TestWebhookServiceCreateWebhook: # Verify assert result['enabled'] is False + _assert_webhook_write(ap, 'insert', {'enabled': False, 'workspace_uuid': WORKSPACE_UUID}) async def test_create_webhook_rejects_workspace_at_capacity(self): ap = SimpleNamespace( @@ -448,7 +489,8 @@ class TestWebhookServiceDeleteWebhook: await service.delete_webhook(WORKSPACE_UUID, 1) # Verify - ap.persistence_mgr.execute_async.assert_called_once() + ap.persistence_mgr.execute_async.assert_awaited_once() + _assert_webhook_write(ap, 'delete', 1) async def test_delete_webhook_nonexistent_id(self): """Delete operation completes even for nonexistent ID.""" @@ -463,7 +505,8 @@ class TestWebhookServiceDeleteWebhook: await service.delete_webhook(WORKSPACE_UUID, 999) # Verify - still called - ap.persistence_mgr.execute_async.assert_called_once() + ap.persistence_mgr.execute_async.assert_awaited_once() + _assert_webhook_write(ap, 'delete', 999) class TestWebhookServiceGetEnabledWebhooks: diff --git a/tests/unit_tests/api/test_adapter_session_scoping.py b/tests/unit_tests/api/test_adapter_session_scoping.py index 4eb28d1b4..e6bf6ca6c 100644 --- a/tests/unit_tests/api/test_adapter_session_scoping.py +++ b/tests/unit_tests/api/test_adapter_session_scoping.py @@ -190,10 +190,17 @@ async def test_session_capacity_evicts_oldest_session_in_same_workspace(): _bind_session_scope(session, owner_context) sessions[f'session-{index}'] = session + foreign_task = SimpleNamespace(done=Mock(return_value=False), cancel=Mock()) + foreign = {'created_at': -1.0, 'task': foreign_task} + _bind_session_scope(foreign, _request_context(workspace_uuid='workspace-b')) + sessions['foreign'] = foreign + _make_room_for_session(sessions, owner_context) assert 'session-0' not in sessions - assert len(sessions) == 9 + assert len(sessions) == 10 + assert sessions['foreign'] is foreign + foreign_task.cancel.assert_not_called() tasks[0].cancel.assert_called_once_with() diff --git a/tests/unit_tests/api/test_apikey_service.py b/tests/unit_tests/api/test_apikey_service.py index c4fc4c12c..34390dcac 100644 --- a/tests/unit_tests/api/test_apikey_service.py +++ b/tests/unit_tests/api/test_apikey_service.py @@ -3,10 +3,12 @@ from __future__ import annotations from types import SimpleNamespace from unittest.mock import AsyncMock, Mock +import hashlib +import sqlalchemy import pytest from langbot.pkg.api.http.service.apikey import ApiKeyService -from langbot.pkg.entity.persistence.apikey import ApiKeyStatus +from langbot.pkg.entity.persistence.apikey import ApiKey, ApiKeyStatus @pytest.mark.asyncio @@ -76,6 +78,27 @@ async def test_verify_api_key_keeps_db_validation_for_lbk_keys(key_exists): result = await service.verify_api_key('lbk_valid_format') assert result is key_exists + digest = hashlib.sha256(b'lbk_valid_format').hexdigest() + statements = [call.args[0] for call in persistence_mgr.execute_async.await_args_list] + assert statements[0].whereclause.compare(ApiKey.key_hash == digest) + if key_exists: + assert statements[1].whereclause.compare( + sqlalchemy.and_( + ApiKey.id == 1, + ApiKey.workspace_uuid == 'workspace-a', + ApiKey.key_hash == digest, + ) + ) + assert statements[2].is_update + assert statements[2].whereclause.compare( + sqlalchemy.and_( + ApiKey.id == 1, + ApiKey.workspace_uuid == 'workspace-a', + ApiKey.key_hash == digest, + ApiKey.status == ApiKeyStatus.ACTIVE.value, + ) + ) + assert statements[2].compile().params['last_used_at'] is not None assert persistence_mgr.execute_async.await_count == (3 if key_exists else 1) if key_exists: workspace_service.get_execution_binding.assert_awaited_once_with('workspace-a') diff --git a/tests/unit_tests/api/test_file_upload_scoping.py b/tests/unit_tests/api/test_file_upload_scoping.py index 0463a4c84..7d13f92dc 100644 --- a/tests/unit_tests/api/test_file_upload_scoping.py +++ b/tests/unit_tests/api/test_file_upload_scoping.py @@ -9,6 +9,7 @@ import quart from quart.datastructures import FileStorage from langbot.pkg.api.http.controller.groups.files import FilesRouterGroup +from langbot.pkg.api.http.context import RequestContext pytestmark = pytest.mark.asyncio @@ -52,6 +53,13 @@ async def test_document_upload_uses_dedicated_scoped_owner_type(): assert response.status_code == 200 assert (await response.get_json())['data']['file_id'] == 'scoped-document-key' + storage_mgr.save_scoped.assert_awaited_once() + context = storage_mgr.save_scoped.await_args.args[0] + assert isinstance(context, RequestContext) + assert context.instance_uuid == 'instance-test' + assert context.workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert context.placement_generation == 3 + assert context.account_uuid == 'account-test' kwargs = storage_mgr.save_scoped.await_args.kwargs assert kwargs['owner_type'] == 'upload_document' assert kwargs['owner'] == 'account:account-test' diff --git a/tests/unit_tests/api/test_provider_controller_secrets.py b/tests/unit_tests/api/test_provider_controller_secrets.py index 78c72ba8e..73d9fe990 100644 --- a/tests/unit_tests/api/test_provider_controller_secrets.py +++ b/tests/unit_tests/api/test_provider_controller_secrets.py @@ -62,9 +62,7 @@ async def _create_client(role: str): application.provider_service = SimpleNamespace( get_providers=AsyncMock(side_effect=get_providers), get_provider=AsyncMock(side_effect=get_provider), - get_provider_model_counts=AsyncMock( - return_value={'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1} - ), + get_provider_model_counts=AsyncMock(return_value={'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1}), ) def model_service(list_name: str, get_name: str): @@ -136,8 +134,15 @@ async def test_default_preserves_secrets_and_explicit_false_redacts_high_permiss false_data = await false_response.get_json() default_value = default_data['data'].get('providers', default_data['data'].get('models'))[0] false_value = false_data['data'].get('providers', false_data['data'].get('models'))[0] - assert '***' not in str(default_value) + expected = ( + {**RAW_PROVIDER, 'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1} + if endpoint.endswith('/providers') + else RAW_MODEL + ) + assert default_value == expected assert '***' in str(false_value) + assert 'provider-secret' not in str(false_data) + assert 'model-secret' not in str(false_data) @pytest.mark.parametrize( @@ -158,6 +163,8 @@ async def test_explicit_true_does_not_grant_low_permission_reads(endpoint): data = await response.get_json() value = data['data'].get('providers', data['data'].get('models'))[0] assert '***' in str(value) + assert 'provider-secret' not in str(data) + assert 'model-secret' not in str(data) @pytest.mark.parametrize( diff --git a/tests/unit_tests/api/test_user_reset_password.py b/tests/unit_tests/api/test_user_reset_password.py index 4ae70d90d..68ae5cfd0 100644 --- a/tests/unit_tests/api/test_user_reset_password.py +++ b/tests/unit_tests/api/test_user_reset_password.py @@ -16,7 +16,7 @@ import asyncio import logging import time from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock, call import pytest import quart @@ -65,13 +65,17 @@ def _make_genkeys_ap(existing_key: str) -> SimpleNamespace: ) -async def test_recovery_key_generation_is_short_and_unambiguous(): +async def test_recovery_key_generation_is_short_and_unambiguous(monkeypatch): """Eight random base32 characters balance manual entry and online throttling.""" ap = _make_genkeys_ap(existing_key='') + choice = Mock(side_effect=list('ZYXW9876')) + monkeypatch.setattr('langbot.pkg.core.stages.genkeys.secrets.choice', choice) await GenKeysStage().run(ap) key = ap.instance_config.data['system']['recovery_key'] + assert key == 'ZYXW9876' + assert choice.call_args_list == [call('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')] * 8 assert len(key) == 8 assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ') assert ap.instance_config.dump_config.called @@ -198,7 +202,7 @@ async def test_non_string_recovery_key_does_not_crash(): @pytest.mark.parametrize('key', ['奇数密钥不是ASCII', '\ud800', '\udfff']) async def test_non_ascii_recovery_key_does_not_crash(key): """Non-ASCII keys must compare safely (encode-based constant-time compare).""" - client, _, _ = await _create_client() + client, reset_password, _ = await _create_client() resp = await client.post( '/api/v1/user/reset-password', @@ -206,6 +210,7 @@ async def test_non_ascii_recovery_key_does_not_crash(key): ) assert resp.status_code == 403 + reset_password.assert_not_awaited() async def test_quota_exhausted_after_max_attempts(): diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 1d680ba7a..aa7471411 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -434,7 +434,7 @@ async def test_box_service_get_sessions_delegates_to_client(): sessions = await service.get_sessions(_CONTEXT) assert sessions == [{'session_id': 'test-session'}] - client.get_sessions.assert_awaited_once() + client.get_sessions.assert_awaited_once_with(action_context=_ACTION_CONTEXT) @pytest.mark.asyncio @@ -599,12 +599,14 @@ async def test_cloud_box_service_reconnect_restores_runtime_only( connector.reconnect = AsyncMock() service._ensure_default_workspace = Mock() service._verify_cloud_runtime = AsyncMock() + service._purge_attachment_dirs = AsyncMock() monkeypatch.setattr('langbot.pkg.box.service.asyncio.sleep', AsyncMock()) await service._reconnect_loop(connector) connector.reconnect.assert_awaited_once() service._verify_cloud_runtime.assert_awaited_once() + service._purge_attachment_dirs.assert_not_awaited() assert service.available is True @@ -674,6 +676,8 @@ async def test_box_service_allows_host_mount_under_configured_root(tmp_path): assert result['ok'] is True assert backend.start_calls == ['person_test_user'] + assert backend.start_specs[0].host_path == os.path.realpath(host_dir) + assert backend.start_specs[0].host_path_mode == BoxHostMountMode.READ_WRITE @pytest.mark.asyncio @@ -744,16 +748,20 @@ async def test_box_service_rejects_host_mount_outside_allowed_roots(tmp_path): service = BoxService(make_app(logger, [str(allowed_root)]), client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() - with pytest.raises(BoxValidationError): + with pytest.raises(BoxValidationError, match='outside allowed_mount_roots'): await service.execute_spec_payload( { 'cmd': 'pwd', 'host_path': str(disallowed_root), - 'session_id': '12', + 'session_id': 'person_test_user', }, make_query(12), ) + assert backend.start_calls == [] + assert backend.exec_calls == [] + assert list(disallowed_root.iterdir()) == [] + @pytest.mark.asyncio async def test_box_runtime_rejects_host_mount_conflict_in_same_session(tmp_path): @@ -1064,8 +1072,18 @@ def test_builtin_profiles_are_consistent(): @pytest.mark.asyncio -async def test_profile_default_applies_resource_limits(): +async def test_profile_default_applies_resource_limits(monkeypatch): """Default profile resource limits are applied to BoxSpec.""" + profile = BUILTIN_PROFILES['default'].model_copy( + update={ + 'cpus': 0.75, + 'memory_mb': 320, + 'pids_limit': 37, + 'read_only_rootfs': False, + 'workspace_quota_mb': 19, + } + ) + monkeypatch.setitem(BUILTIN_PROFILES, 'default', profile) logger = Mock() backend = FakeBackend(logger) runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) @@ -1473,8 +1491,14 @@ async def test_rpc_client_get_status(): try: status = await client.get_status(action_context=_ACTION_CONTEXT) - assert 'backend' in status - assert 'active_sessions' in status + assert status['backend']['name'] == 'fake' + assert status['backend']['available'] is True + assert status['active_sessions'] == 0 + await client.execute(BoxSpec(cmd='true', session_id='status-session'), action_context=_ACTION_CONTEXT) + status = await client.get_status(action_context=_ACTION_CONTEXT) + assert status['active_sessions'] == 1 + assert status['managed_processes'] == 0 + assert status['session_ttl_sec'] == 300 finally: server_task.cancel() client_task.cancel() @@ -1515,8 +1539,10 @@ async def test_rpc_client_delete_session(): spec = BoxSpec.model_validate({'cmd': 'echo hi', 'session_id': 'r-del-1'}) await client.execute(spec, action_context=_ACTION_CONTEXT) + physical_session_id = backend.start_calls[0] await client.delete_session('r-del-1', action_context=_ACTION_CONTEXT) + assert backend.stop_calls == [physical_session_id] sessions = await client.get_sessions(action_context=_ACTION_CONTEXT) assert len(sessions) == 0 finally: @@ -1884,21 +1910,6 @@ class TestBuildSkillExecutionMounts: with pytest.raises(ValueError, match='no immutable package root'): skill_loader.build_execution_mounts(app, query) - def test_empty_package_root_skipped_even_when_not_shared(self): - """An empty package_root is always invalid regardless of topology.""" - logger = Mock() - skills = { - 'no_root': { - 'name': 'no_root', - 'package_root': '', - 'manifest_path': '', - 'revision': 'sha256:' + '1' * 64, - } - } - app = self._make_app(logger, skills) - - assert skill_loader.build_execution_mounts(app, make_query()) == [] - def test_returns_empty_when_no_skill_manager(self): logger = Mock() app = make_app(logger) @@ -1973,13 +1984,26 @@ class TestAttachmentHelpers: class TestInboundOutboundRoundTrip: + @staticmethod + async def _execute_command(command, workspace): + process = await asyncio.create_subprocess_exec( + '/bin/sh', + '-c', + command.replace('/workspace', str(workspace)), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + assert process.returncode == 0, stderr.decode() + return stdout.decode().replace(str(workspace), '/workspace') + def _service(self) -> BoxService: service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) service._available = True return service @pytest.mark.asyncio - async def test_materialize_inbound_writes_and_describes(self): + async def test_materialize_inbound_writes_and_describes(self, tmp_path): import base64 import langbot_plugin.api.entities.builtin.platform.message as platform_message @@ -1997,14 +2021,9 @@ class TestInboundOutboundRoundTrip: ] ) - # Mock the sandbox write path: echo back the written paths. async def fake_execute_tool(parameters, q): - assert '/workspace/inbox/' in parameters['command'] - return { - 'ok': True, - 'stdout': '["/workspace/inbox/query-42/image_1.png"]', - 'stderr': '', - } + assert q is query + return {'ok': True, 'stdout': await self._execute_command(parameters['command'], tmp_path), 'stderr': ''} service.execute_tool = AsyncMock(side_effect=fake_execute_tool) @@ -2014,6 +2033,7 @@ class TestInboundOutboundRoundTrip: assert d['type'] == 'Image' assert d['path'] == '/workspace/inbox/query-42/image_1.png' assert d['size'] == len(img_bytes) + assert (tmp_path / 'inbox/query-42/image_1.png').read_bytes() == img_bytes @pytest.mark.asyncio async def test_materialize_inbound_noop_without_attachments(self): @@ -2027,45 +2047,40 @@ class TestInboundOutboundRoundTrip: service.execute_tool.assert_not_called() @pytest.mark.asyncio - async def test_collect_outbound_reads_and_clears(self): + async def test_collect_outbound_reads_and_clears(self, tmp_path): service = self._service() query = make_query() - - calls = [] + outbox = tmp_path / 'outbox/query-42' + outbox.mkdir(parents=True) + (outbox / 'out.png').write_bytes(b'ABC') + guard = tmp_path / 'outbox/query-other/protected.txt' + guard.parent.mkdir() + guard.write_bytes(b'other query') async def fake_client_execute(spec, *, action_context=None): - assert action_context.workspace_uuid == query.workspace_uuid - cmd = spec.cmd - calls.append(cmd) - if 'os.scandir' in cmd: - return BoxExecutionResult( - session_id='s', - backend_name='test', - status=BoxExecutionStatus.COMPLETED, - exit_code=0, - stdout='[{"name": "out.png", "b64": "QUJD"}]', - duration_ms=10, - ) - # the rm -rf cleanup call + assert action_context == _ACTION_CONTEXT return BoxExecutionResult( session_id='s', backend_name='test', status=BoxExecutionStatus.COMPLETED, exit_code=0, - stdout='', + stdout=await self._execute_command(spec.cmd, tmp_path), duration_ms=10, ) + async def fake_execute_tool(parameters, q): + assert q is query + return {'ok': True, 'stdout': await self._execute_command(parameters['command'], tmp_path), 'stderr': ''} + service.client.execute = AsyncMock(side_effect=fake_client_execute) - service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''}) + service.execute_tool = AsyncMock(side_effect=fake_execute_tool) attachments = await service.collect_outbound_attachments(query) - assert len(attachments) == 1 - assert attachments[0]['type'] == 'Image' - assert attachments[0]['name'] == 'out.png' - # cleanup (rm -rf) must have been issued after a successful collection - service.execute_tool.assert_awaited_once() - assert 'rm -rf' in service.execute_tool.await_args.args[0]['command'] + + assert attachments == [{'type': 'Image', 'name': 'out.png', 'base64': 'data:image/png;base64,QUJD'}] + assert not (outbox / 'out.png').exists() + assert guard.read_bytes() == b'other query' + assert await service.collect_outbound_attachments(query) == [] @pytest.mark.asyncio async def test_collect_outbound_empty_still_clears(self): @@ -2102,9 +2117,9 @@ class TestInboundOutboundRoundTrip: service.client.execute = AsyncMock(side_effect=fake_client_execute) service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''}) assert await service.collect_outbound_attachments(query) == [] - # cleanup (rm -rf) is issued unconditionally now + # Cleanup is issued even when the outbox is empty. service.execute_tool.assert_awaited_once() - assert 'rm -rf' in service.execute_tool.await_args.args[0]['command'] + assert 'clear_directory' in service.execute_tool.await_args.args[0]['command'] @pytest.mark.asyncio async def test_passthrough_noop_when_unavailable(self): @@ -2172,6 +2187,7 @@ class TestAttachmentHostPath: stale_dir = os.path.join(ws, 'inbox', 'query-42') os.makedirs(stale_dir, exist_ok=True) pathlib.Path(stale_dir, 'image_1.png').write_bytes(b'STALE-OLD-IMAGE') + pathlib.Path(stale_dir, 'leftover-secret.txt').write_bytes(b'STALE-OTHER-FILE') new = b'\x89PNG\r\n\x1a\n NEW' b64 = 'data:image/png;base64,' + base64.b64encode(new).decode() @@ -2185,6 +2201,7 @@ class TestAttachmentHostPath: assert host_bytes == new # No leftover content from the stale image. assert b'STALE-OLD-IMAGE' not in host_bytes + assert sorted(os.listdir(stale_dir)) == ['image_1.png'] @pytest.mark.asyncio async def test_inbound_host_replaces_query_symlink_without_touching_other_workspace(self, tmp_path): diff --git a/tests/unit_tests/box/test_outbox_permissions.py b/tests/unit_tests/box/test_outbox_permissions.py new file mode 100644 index 000000000..0adf9f8e5 --- /dev/null +++ b/tests/unit_tests/box/test_outbox_permissions.py @@ -0,0 +1,319 @@ +"""Permission recovery must keep sandbox exports inside the bound run.""" + +import asyncio +import errno +import os +import subprocess +import sys +from pathlib import Path +from unittest.mock import AsyncMock, Mock + +import pytest +from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus +from langbot.pkg.box import secure_fs +from tests.unit_tests.box import test_box_service as fixtures + + +@pytest.mark.parametrize('denied_component', ['root', 'outbox', 'run', 'nested', 'answer.txt']) +def test_unreadable_host_file_is_not_silently_dropped(tmp_path, monkeypatch, denied_component): + outbox = tmp_path / 'outbox' / 'run' / 'nested' + outbox.mkdir(parents=True) + (outbox / 'answer.txt').write_bytes(b'private output') + original_open = os.open + + def denied_open(path, flags, *args, **kwargs): + target = str(tmp_path) if denied_component == 'root' else denied_component + if path == target: + raise PermissionError(errno.EACCES, 'container-owned output', path) + return original_open(path, flags, *args, **kwargs) + + monkeypatch.setattr(secure_fs.os, 'open', denied_open) + with pytest.raises(PermissionError): + secure_fs.read_regular_files( + str(tmp_path), 'outbox', 'run', max_file_bytes=1024, max_files=20, max_total_bytes=4096 + ) + + +def test_host_outbox_skips_fifo_without_waiting_for_a_writer(tmp_path): + outbox = tmp_path / 'outbox' / 'run' + outbox.mkdir(parents=True) + os.mkfifo(outbox / 'pipe') + script = ( + 'from langbot.pkg.box import secure_fs; ' + f'assert secure_fs.read_regular_files({str(tmp_path)!r}, "outbox", "run", ' + 'max_file_bytes=1024, max_files=20, max_total_bytes=4096) == []' + ) + subprocess.run([sys.executable, '-c', script], check=True, timeout=3) + + +@pytest.mark.asyncio +async def test_unreadable_host_output_uses_the_bound_sandbox(tmp_path): + service, workspace = fixtures.TestAttachmentHostPath()._service_with_workspace(tmp_path) + query = fixtures.make_query() + outbox = Path(workspace) / 'outbox' + current = outbox / 'query-42' + current.mkdir(parents=True) + (current / 'answer.txt').write_bytes(b'private output') + (current / ' answer.txt ').write_bytes(b'spaced output') + other = outbox / 'other-run' + other.mkdir() + (other / 'secret.txt').write_bytes(b'other run secret') + (current / 'leak.txt').symlink_to(other / 'secret.txt') + service.OUTBOX_MOUNT_DIR = str(outbox) + service._read_outbox_host = Mock(side_effect=PermissionError('container-owned output')) + service._clear_outbox = AsyncMock() + observed = [] + + async def execute(spec, *, action_context): + assert action_context == fixtures._ACTION_CONTEXT + assert spec.session_id == 'person_test_user' + assert spec.host_path == workspace + process = await asyncio.create_subprocess_shell( + spec.cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + observed.append(spec) + return BoxExecutionResult( + session_id=spec.session_id, + backend_name='test', + status=BoxExecutionStatus.COMPLETED, + exit_code=process.returncode, + stdout=stdout.decode(), + stderr=stderr.decode(), + duration_ms=0, + ) + + service.client.execute = AsyncMock(side_effect=execute) + attachments = await service.collect_outbound_attachments(query) + assert {item['name']: item['base64'] for item in attachments} == { + 'answer.txt': 'cHJpdmF0ZSBvdXRwdXQ=', + ' answer.txt ': 'c3BhY2VkIG91dHB1dA==', + } + assert len(observed) == 1 + assert (other / 'secret.txt').read_bytes() == b'other run secret' + service._clear_outbox.assert_awaited_once_with(query, str(current)) + + +def _script(root, operation='read', **limits): + from langbot.pkg.box.sandbox_files import outbox_script + + return outbox_script( + str(root / 'workspace' / 'outbox'), + 'run', + operation=operation, + max_file_bytes=limits.get('max_file_bytes', 1024), + max_files=limits.get('max_files', 20), + max_total_bytes=limits.get('max_total_bytes', 4096), + ) + + +def _run_script(script): + return subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, timeout=3) + + +@pytest.mark.parametrize('operation', ['read', 'clear']) +@pytest.mark.parametrize('component', ['workspace', 'outbox', 'run']) +def test_sandbox_outbox_never_follows_ancestor_links(tmp_path, operation, component): + workspace = tmp_path / 'workspace' + current = workspace / 'outbox' / 'run' + current.mkdir(parents=True) + other = tmp_path / 'other' + other.mkdir() + target = { + 'workspace': other / 'outbox' / 'run', + 'outbox': other / 'run', + 'run': other, + }[component] + target.mkdir(parents=True, exist_ok=True) + protected = target / 'secret.txt' + protected.write_bytes(b'other run secret') + import shutil + + link = {'workspace': workspace, 'outbox': current.parent, 'run': current}[component] + shutil.rmtree(link) + link.symlink_to(other, target_is_directory=True) + result = _run_script(_script(tmp_path, operation)) + assert result.returncode != 0 + assert protected.read_bytes() == b'other run secret' + + +@pytest.mark.parametrize('replacement', ['symlink', 'fifo', 'directory']) +def test_sandbox_file_open_resists_type_changes(tmp_path, replacement): + import json + + current = tmp_path / 'workspace' / 'outbox' / 'run' + current.mkdir(parents=True) + (current / 'answer.txt').write_bytes(b'expected') + secret = tmp_path / 'secret.txt' + secret.write_bytes(b'outside secret') + # Change the real filesystem after classification, immediately before open. + prelude = f""" +import os +original_open = os.open +replacement = {replacement!r} +def racing_open(path, flags, *args, **kwargs): + fd = kwargs.get('dir_fd') + if path == 'answer.txt' and fd is not None: + os.unlink(path, dir_fd=fd) + if replacement == 'symlink': + os.symlink({str(secret)!r}, path, dir_fd=fd) + elif replacement == 'fifo': + os.mkfifo(path, dir_fd=fd) + else: + os.mkdir(path, dir_fd=fd) + child = original_open(path, os.O_RDONLY | os.O_DIRECTORY, dir_fd=fd) + try: + output = original_open('secret.txt', os.O_WRONLY | os.O_CREAT, 0o600, dir_fd=child) + os.write(output, b'unexpected directory') + os.close(output) + finally: + os.close(child) + return original_open(path, flags, *args, **kwargs) +os.open = racing_open +""" + result = _run_script(prelude + _script(tmp_path)) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [] + assert secret.read_bytes() == b'outside secret' + + +@pytest.mark.parametrize('limit', ['file_bytes', 'total_bytes', 'total_bytes_exact', 'entries', 'directories']) +def test_sandbox_output_limits_fail_without_removing_files(tmp_path, limit): + current = tmp_path / 'workspace' / 'outbox' / 'run' + current.mkdir(parents=True) + options = {} + if limit == 'file_bytes': + (current / 'answer.txt').write_bytes(b'1234') + options['max_file_bytes'] = 3 + elif limit in {'total_bytes', 'total_bytes_exact'}: + (current / 'a.txt').write_bytes(b'12') + (current / 'b.txt').write_bytes(b'34') + options['max_total_bytes'] = 3 + if limit == 'total_bytes_exact': + (current / 'c.txt').write_bytes(b'56') + options['max_total_bytes'] = 4 + elif limit == 'entries': + for index in range(513): + (current / str(index)).symlink_to(tmp_path / 'missing') + else: + for index in range(65): + (current / str(index)).mkdir() + before = set(current.iterdir()) + result = _run_script(_script(tmp_path, **options)) + assert result.returncode != 0 + assert 'limit' in result.stderr + assert set(current.iterdir()) == before + + +def test_sandbox_output_count_and_depth_are_bounded(tmp_path): + import json + + current = tmp_path / 'workspace' / 'outbox' / 'run' + current.mkdir(parents=True) + (current / 'a.txt').write_bytes(b'a') + (current / 'b.txt').write_bytes(b'b') + result = _run_script(_script(tmp_path, max_files=1)) + assert result.returncode == 0, result.stderr + assert len(json.loads(result.stdout)) == 1 + (current / 'a.txt').unlink() + (current / 'b.txt').unlink() + deep = current.joinpath(*('nested' for _ in range(9))) + deep.mkdir(parents=True) + (deep / 'too-deep.txt').write_bytes(b'not exported') + result = _run_script(_script(tmp_path)) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [] + + +@pytest.mark.parametrize('operation', ['read', 'clear']) +def test_sandbox_nested_links_do_not_touch_their_targets(tmp_path, operation): + import json + + current = tmp_path / 'workspace' / 'outbox' / 'run' + current.mkdir(parents=True) + other = tmp_path / 'other' + other.mkdir() + protected = other / 'secret.txt' + protected.write_bytes(b'protected') + (current / 'linked-dir').symlink_to(other, target_is_directory=True) + (current / 'linked-file').symlink_to(protected) + result = _run_script(_script(tmp_path, operation)) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [] + assert protected.read_bytes() == b'protected' + if operation == 'clear': + assert list(current.iterdir()) == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['permission', 'truncated-json']) +async def test_permission_recovery_failure_does_not_clear_output(tmp_path, failure): + from langbot_plugin.box.errors import BoxValidationError + + service, workspace = fixtures.TestAttachmentHostPath()._service_with_workspace(tmp_path) + query = fixtures.make_query() + current = Path(workspace) / 'outbox' / 'query-42' + current.mkdir(parents=True) + output = current / 'answer.txt' + output.write_bytes(b'keep on failure') + service._read_outbox_host = Mock(side_effect=PermissionError('container-owned output')) + service._clear_outbox = AsyncMock() + service.client.execute = AsyncMock( + return_value=BoxExecutionResult( + session_id='person_test_user', + backend_name='test', + status=BoxExecutionStatus.COMPLETED, + exit_code=1 if failure == 'permission' else 0, + stdout='' if failure == 'permission' else '[{"name": "answer.txt", "b64": "truncated', + stderr='Permission denied' if failure == 'permission' else '', + duration_ms=0, + ) + ) + error = 'Failed to read sandbox outbox' if failure == 'permission' else 'invalid attachment data' + with pytest.raises(BoxValidationError, match=error): + await service.collect_outbound_attachments(query) + service._clear_outbox.assert_not_awaited() + assert output.read_bytes() == b'keep on failure' + + +@pytest.mark.asyncio +async def test_changed_outbox_ancestor_never_uses_cleanup_exec(tmp_path): + service, workspace = fixtures.TestAttachmentHostPath()._service_with_workspace(tmp_path) + query = fixtures.make_query() + outbox = Path(workspace) / 'outbox' + other = tmp_path / 'other' + protected = other / 'query-42' / 'secret.txt' + protected.parent.mkdir(parents=True) + protected.write_bytes(b'keep other run') + outbox.symlink_to(other, target_is_directory=True) + service.execute_tool = AsyncMock(side_effect=AssertionError('unsafe host ancestor must not use exec')) + await service._clear_outbox(query, str(outbox / 'query-42')) + service.execute_tool.assert_not_awaited() + assert protected.read_bytes() == b'keep other run' + assert any('unsafe sandbox outbox' in str(call.args[0]) for call in service.ap.logger.warning.call_args_list) + + +def test_sandbox_cleanup_has_a_bounded_inode_budget(tmp_path): + current = tmp_path / 'workspace' / 'outbox' / 'run' + current.mkdir(parents=True) + for index in range(4097): + (current / str(index)).write_bytes(b'') + result = _run_script(_script(tmp_path, 'clear')) + assert result.returncode != 0 + assert 'inode limit' in result.stderr + assert list(current.iterdir()) + + +def test_host_reset_preserves_permission_error_for_private_run_directory(tmp_path, monkeypatch): + current = tmp_path / 'outbox' / 'run' + current.mkdir(parents=True) + original_open = os.open + + def denied_open(path, flags, *args, **kwargs): + if path == 'run' and kwargs.get('dir_fd') is not None: + raise PermissionError(errno.EACCES, 'private container directory', path) + return original_open(path, flags, *args, **kwargs) + + monkeypatch.setattr(secure_fs.os, 'open', denied_open) + with pytest.raises(PermissionError): + secure_fs.reset_directory(str(tmp_path), 'outbox', 'run') diff --git a/tests/unit_tests/cloud/test_directory_projection.py b/tests/unit_tests/cloud/test_directory_projection.py index 756baa7be..cc2a28e88 100644 --- a/tests/unit_tests/cloud/test_directory_projection.py +++ b/tests/unit_tests/cloud/test_directory_projection.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio import datetime import logging +import sqlite3 +from contextlib import closing from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -184,7 +186,16 @@ def _delta( async def test_directory_delta_requests_model_catalog_sync_after_commit(projection_context): application, _session_factory = projection_context - request_sync = Mock() + observed_revisions = [] + + def observe_committed_revision(): + with closing(sqlite3.connect(application.persistence_mgr.engine.url.database)) as connection: + row = connection.execute( + 'SELECT projection_revision FROM workspaces WHERE uuid = ?', (WORKSPACE_UUID,) + ).fetchone() + observed_revisions.append(row[0]) + + request_sync = Mock(side_effect=observe_committed_revision) application.cloud_model_catalog_service = SimpleNamespace(request_sync=request_sync) event = DirectoryEvent( cursor=2, @@ -209,10 +220,12 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti ) await service.initialize() request_sync.reset_mock() + observed_revisions.clear() await service.sync_once() request_sync.assert_called_once_with() + assert observed_revisions == [2] async def test_targeted_reconciliation_projects_new_workspace_without_advancing_event_cursor(projection_context): @@ -247,11 +260,7 @@ async def test_targeted_reconciliation_projects_new_workspace_without_advancing_ async def test_targeted_reconciliation_preserves_existing_account_until_ordered_event_projection(projection_context): application, session_factory = projection_context targeted_workspace = _workspace(revision=8, name='Renamed Workspace').model_copy( - update={ - 'members': [ - _member(revision=8).model_copy(update={'display_name': 'Changed Account Name'}) - ] - } + update={'members': [_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})]} ) provider = _Provider( [_snapshot(7)], @@ -572,7 +581,13 @@ async def test_account_projection_reads_large_directory_in_bounded_batches(proje assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(User)) == 501 -async def test_archived_and_absent_workspaces_are_execution_fenced(projection_context): +@pytest.mark.parametrize( + ('status', 'execution_state', 'write_fenced', 'membership_status'), + [('archived', 'inactive', True, 'removed'), ('active', 'active', False, 'active')], +) +async def test_archived_and_absent_workspaces_are_execution_fenced( + projection_context, status, execution_state, write_fenced, membership_status +): application, session_factory = projection_context service = DirectoryProjectionService( application, @@ -584,17 +599,17 @@ async def test_archived_and_absent_workspaces_are_execution_fenced(projection_co await service.apply_snapshot( _snapshot( 2, - workspaces=[_workspace(revision=2, status='archived')], + workspaces=[_workspace(revision=2, status=status)], ) ) async with session_factory() as session: workspace = await session.get(Workspace, WORKSPACE_UUID) execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID) membership = await session.scalar(sqlalchemy.select(WorkspaceMembership)) - assert workspace.status == 'archived' - assert execution.state == 'inactive' - assert execution.write_fenced is True - assert membership.status == 'removed' + assert workspace.status == status + assert execution.state == execution_state + assert execution.write_fenced is write_fenced + assert membership.status == membership_status await service.apply_snapshot(_snapshot(3, workspaces=[])) async with session_factory() as session: @@ -746,7 +761,15 @@ async def test_missing_workspace_in_delta_fences_only_requested_workspace(projec high_water_cursor=2, events=[event], ) - provider = _Provider([_snapshot(1)], [batch], [_delta(workspaces=[])]) + second_workspace = _workspace().model_copy( + update={ + 'uuid': SECOND_WORKSPACE_UUID, + 'slug': 'second-workspace', + 'type': 'team', + 'members': [_member().model_copy(update={'membership_uuid': SECOND_MEMBERSHIP_UUID})], + } + ) + provider = _Provider([_snapshot(1, workspaces=[_workspace(), second_workspace])], [batch], [_delta(workspaces=[])]) service = DirectoryProjectionService(application, provider, INSTANCE_UUID) await service.initialize() @@ -755,7 +778,12 @@ async def test_missing_workspace_in_delta_fences_only_requested_workspace(projec async with session_factory() as session: workspace = await session.get(Workspace, WORKSPACE_UUID) execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID) - membership = await session.scalar(sqlalchemy.select(WorkspaceMembership)) + membership = await session.scalar( + sqlalchemy.select(WorkspaceMembership).where(WorkspaceMembership.workspace_uuid == WORKSPACE_UUID) + ) + untouched_workspace = await session.get(Workspace, SECOND_WORKSPACE_UUID) + untouched_execution = await session.get(WorkspaceExecutionState, SECOND_WORKSPACE_UUID) + untouched_membership = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID) assert workspace.status == 'archived' assert workspace.projection_revision == 2 assert execution.state == 'inactive' @@ -763,6 +791,12 @@ async def test_missing_workspace_in_delta_fences_only_requested_workspace(projec assert execution.desired_state_revision == 2 assert membership.status == 'removed' assert membership.projection_revision == 2 + assert untouched_workspace.status == 'active' + assert untouched_workspace.projection_revision == 1 + assert untouched_execution.state == 'active' + assert untouched_execution.write_fenced is False + assert untouched_membership.status == 'active' + assert untouched_membership.projection_revision == 1 stale_same_cursor_snapshot = _snapshot( 2, diff --git a/tests/unit_tests/command/test_cmdmgr.py b/tests/unit_tests/command/test_cmdmgr.py index c4c9156a6..9510a84b2 100644 --- a/tests/unit_tests/command/test_cmdmgr.py +++ b/tests/unit_tests/command/test_cmdmgr.py @@ -437,8 +437,10 @@ class TestEmptyAndEdgeInputs: async for ret in mgr.execute(' ', '/ ', query, session): results.append(ret) - # Should yield error - assert len(results) >= 1 + assert len(results) == 1 + assert results[0].error is not None + assert results[0].error.message == '未知命令: ' + assert results[0].text is None @pytest.mark.asyncio async def test_initialize_with_deep_nesting(self): diff --git a/tests/unit_tests/config/test_config_loader.py b/tests/unit_tests/config/test_config_loader.py index ec9942076..edc315bbf 100644 --- a/tests/unit_tests/config/test_config_loader.py +++ b/tests/unit_tests/config/test_config_loader.py @@ -13,6 +13,7 @@ from __future__ import annotations import pytest import json +import yaml from langbot.pkg.config.impls.yaml import YAMLConfigFile from langbot.pkg.config.impls.json import JSONConfigFile @@ -126,8 +127,7 @@ name: custom_name assert config_file.exists() content = config_file.read_text() - assert 'saved_app' in content - assert 'new_key' in content + assert yaml.safe_load(content) == {'name': 'saved_app', 'new_key': 'new_value'} def test_yaml_save_sync(self, tmp_path): """YAML config can be saved synchronously.""" @@ -142,7 +142,7 @@ name: custom_name assert config_file.exists() content = config_file.read_text() - assert 'sync_saved' in content + assert yaml.safe_load(content) == {'name': 'sync_saved'} class TestJSONConfigFile: @@ -260,7 +260,7 @@ class TestConfigManager: await manager.dump_config() content = config_file.read_text() - assert 'dumped' in content + assert yaml.safe_load(content) == {'name': 'dumped', 'new_field': 'value'} def test_config_manager_dump_sync(self, tmp_path): """ConfigManager can dump config synchronously.""" @@ -277,6 +277,7 @@ class TestConfigManager: manager.dump_config_sync() assert config_file.exists() + assert yaml.safe_load(config_file.read_text()) == {'name': 'sync_dumped'} class TestConfigExists: diff --git a/tests/unit_tests/core/test_boot.py b/tests/unit_tests/core/test_boot.py index 03c82a732..11319eee3 100644 --- a/tests/unit_tests/core/test_boot.py +++ b/tests/unit_tests/core/test_boot.py @@ -36,6 +36,8 @@ async def test_make_app_shuts_down_partially_built_application(monkeypatch): @pytest.mark.asyncio async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch): captured_handler = {} + print_exc = Mock() + monkeypatch.setattr(boot.traceback, 'print_exc', print_exc) def fake_signal(sig, handler): captured_handler[sig] = handler @@ -48,6 +50,8 @@ async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch await boot.main(SimpleNamespace()) + print_exc.assert_not_called() + @pytest.mark.asyncio async def test_main_signal_handler_disposes_created_app(monkeypatch): diff --git a/tests/unit_tests/core/test_bootutils_deps.py b/tests/unit_tests/core/test_bootutils_deps.py index 13fa156d7..c93604a07 100644 --- a/tests/unit_tests/core/test_bootutils_deps.py +++ b/tests/unit_tests/core/test_bootutils_deps.py @@ -50,8 +50,7 @@ class TestCheckDeps: result = asyncio.run(check_deps()) - assert 'requests' in result - assert 'openai' in result + assert result == ['requests', 'openai'] def test_check_deps_all_missing(self): """check_deps returns all deps when none present.""" @@ -66,8 +65,7 @@ class TestCheckDeps: result = asyncio.run(check_deps()) - # Should include all required_deps keys - assert len(result) == len(required_deps) + assert result == list(required_deps) def test_required_deps_dict_exists(self): """required_deps dictionary is defined.""" diff --git a/tests/unit_tests/core/test_bootutils_log.py b/tests/unit_tests/core/test_bootutils_log.py index fd01eb9c2..e785b7b78 100644 --- a/tests/unit_tests/core/test_bootutils_log.py +++ b/tests/unit_tests/core/test_bootutils_log.py @@ -10,14 +10,10 @@ from __future__ import annotations import logging import os -import re import langbot.pkg.core.bootutils.log as logmod from langbot.pkg.core.bootutils.log import DailyGroupedRotatingFileHandler -# Mirror of the cleanup pattern in api/http/service/maintenance.py. -MAINTENANCE_LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$') - def _listing(directory): return sorted(os.listdir(directory)) @@ -105,6 +101,8 @@ class TestDailyGroupedRotatingFileHandler: assert f'langbot-{day}.log' in files def test_all_filenames_match_maintenance_cleanup_pattern(self, tmp_path, monkeypatch): + from langbot.pkg.api.http.service.maintenance import LOG_FILE_PATTERN + box = {'date': '2026-06-08'} self._patch_date(monkeypatch, box) @@ -117,4 +115,4 @@ class TestDailyGroupedRotatingFileHandler: handler.close() for name in _listing(tmp_path): - assert MAINTENANCE_LOG_FILE_PATTERN.match(name), name + assert LOG_FILE_PATTERN.match(name), name diff --git a/tests/unit_tests/core/test_load_config.py b/tests/unit_tests/core/test_load_config.py index 051597875..b24a36b8f 100644 --- a/tests/unit_tests/core/test_load_config.py +++ b/tests/unit_tests/core/test_load_config.py @@ -119,12 +119,11 @@ class TestApplyEnvOverridesToConfig: """Test that '1', 'yes', 'on' are treated as true.""" load_config = get_load_config_module() - cfg = {'system': {'flag': False}} - for true_val in ['1', 'yes', 'on', 'TRUE']: + cfg = {'system': {'flag': False}} env = {'SYSTEM__FLAG': true_val} with patch.dict(os.environ, env, clear=True): - result = load_config._apply_env_overrides_to_config(cfg.copy()) + result = load_config._apply_env_overrides_to_config(cfg) assert result['system']['flag'] is True def test_override_float_value(self): @@ -237,7 +236,7 @@ class TestApplyEnvOverridesToConfig: with patch.dict(os.environ, env, clear=True): result = load_config._apply_env_overrides_to_config(cfg) - assert result['system']['name'] == 'default' + assert result == {'system': {'name': 'default'}} def test_skip_env_vars_with_empty_path_segments(self, capsys): """Platform variables such as __CF_USER_TEXT_ENCODING are not config.""" @@ -249,7 +248,7 @@ class TestApplyEnvOverridesToConfig: with patch.dict(os.environ, env, clear=True): result = load_config._apply_env_overrides_to_config(cfg) - assert result == cfg + assert result == {'system': {'name': 'default'}} assert capsys.readouterr().out == '' def test_nested_config_path(self): @@ -299,7 +298,7 @@ class TestApplyEnvOverridesToConfig: with patch.dict(os.environ, env, clear=True): result = load_config._apply_env_overrides_to_config(cfg) - assert result == cfg + assert result == {'system': {'name': 'default'}} def test_multiple_env_vars_override(self): """Test multiple env vars applied in order.""" diff --git a/tests/unit_tests/core/test_stage.py b/tests/unit_tests/core/test_stage.py index e9b29e76b..795f52ef6 100644 --- a/tests/unit_tests/core/test_stage.py +++ b/tests/unit_tests/core/test_stage.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect from unittest.mock import MagicMock import pytest @@ -41,11 +42,11 @@ class TestStageClassDecorator: with isolated_sys_modules(mocks): from langbot.pkg.core.stage import stage_class - @stage_class('TestStage') class TestStage: value = 42 - # Class attributes should be preserved + decorated = stage_class('TestStage')(TestStage) + assert decorated is TestStage assert TestStage.value == 42 def test_stage_class_multiple_stages(self): @@ -125,25 +126,15 @@ class TestBootingStageAbstract: # Abstract class has name attribute defined as None assert hasattr(BootingStage, 'name') - @pytest.mark.asyncio - async def test_booting_stage_run_signature(self): - """run method receives Application parameter.""" + def test_booting_stage_run_signature(self): + """The production abstract stage accepts an Application asynchronously.""" mocks = self._make_stage_import_mocks() with isolated_sys_modules(mocks): from langbot.pkg.core.stage import BootingStage - class TestStage(BootingStage): - name = 'TestStage' - - async def run(self, ap): - self.ap_received = ap - - stage = TestStage() - mock_ap = MagicMock() - - await stage.run(mock_ap) - assert stage.ap_received == mock_ap + assert list(inspect.signature(BootingStage.run).parameters) == ['self', 'ap'] + assert inspect.iscoroutinefunction(BootingStage.run) class TestPreregisteredStages: @@ -160,19 +151,3 @@ class TestPreregisteredStages: from langbot.pkg.core.stage import preregistered_stages assert isinstance(preregistered_stages, dict) - - def test_preregistered_stages_key_is_string(self): - """Registry keys are stage names (strings).""" - mocks = self._make_stage_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.stage import stage_class, preregistered_stages - - preregistered_stages.clear() - - @stage_class('MyStage') - class MyStage: - pass - - for key in preregistered_stages: - assert isinstance(key, str) diff --git a/tests/unit_tests/core/test_taskmgr.py b/tests/unit_tests/core/test_taskmgr.py index 3477478bb..85d1657bb 100644 --- a/tests/unit_tests/core/test_taskmgr.py +++ b/tests/unit_tests/core/test_taskmgr.py @@ -15,7 +15,7 @@ import asyncio import contextvars import inspect import sys -from unittest.mock import Mock, MagicMock +from unittest.mock import Mock, MagicMock, patch from contextlib import contextmanager from typing import Generator @@ -181,12 +181,14 @@ class TestTaskContext: ctx.set_current_action('test_action') ctx.trace('Test message') ctx.metadata['key'] = 'value' + ctx.metadata['count'] = 5 + ctx.metadata['items'] = ['a', 'b', 'c'] result = ctx.to_dict() assert result['current_action'] == 'test_action' assert 'Test message' in result['log'] - assert result['metadata'] == {'key': 'value'} + assert result['metadata'] == {'key': 'value', 'count': 5, 'items': ['a', 'b', 'c']} def test_static_new_factory(self): """Test TaskContext.new() factory method.""" @@ -209,19 +211,9 @@ class TestTaskContext: ctx1 = TaskContext.placeholder() ctx2 = TaskContext.placeholder() + assert isinstance(ctx1, TaskContext) assert ctx1 is ctx2 - def test_metadata_is_mutable_dict(self): - """Test that metadata is a mutable dict.""" - TaskContext, _, _ = get_taskmgr_classes() - ctx = TaskContext() - - ctx.metadata['count'] = 5 - ctx.metadata['items'] = ['a', 'b', 'c'] - - assert ctx.metadata['count'] == 5 - assert len(ctx.metadata['items']) == 3 - class TestTaskWrapper: """Tests for TaskWrapper class.""" @@ -346,7 +338,8 @@ class TestTaskWrapper: async def successful_coro(): return {'file_id': 'file-a'} - wrapper = TaskWrapper(mock_app, successful_coro(), kind='knowledge_base.store') + with patch('time.time', return_value=1720000000.25): + wrapper = TaskWrapper(mock_app, successful_coro(), kind='knowledge_base.store') await wrapper.task result = wrapper.to_public_dict() @@ -358,7 +351,7 @@ class TestTaskWrapper: 'status': 'succeeded', 'error': None, 'result': {'file_id': 'file-a'}, - 'created_at': result['created_at'], + 'created_at': 1720000000.25, } assert 'runtime' not in result assert 'traceback' not in str(result).lower() @@ -429,6 +422,29 @@ class TestTaskWrapper: class TestAsyncTaskManager: """Tests for AsyncTaskManager class.""" + @pytest.mark.asyncio + async def test_completed_retention_keeps_newest_tasks_and_running_task(self): + _, _, AsyncTaskManager = get_taskmgr_classes() + mock_app = create_mock_app() + mock_app.instance_config.data['system']['task_retention']['completed_limit'] = 2 + manager = AsyncTaskManager(mock_app) + running = manager.create_task(asyncio.Event().wait()) + + async def immediate_coro(): + return 'done' + + try: + completed = [manager.create_task(immediate_coro()) for _ in range(2)] + await asyncio.gather(*(wrapper.task for wrapper in completed)) + assert manager.tasks == [running, *completed] + + newest = manager.create_task(immediate_coro()) + await newest.task + assert manager.tasks == [running, completed[1], newest] + finally: + running.cancel() + await asyncio.gather(running.task, return_exceptions=True) + @pytest.mark.asyncio async def test_create_task_adds_to_list(self): """Test that create_task adds task to tasks list.""" diff --git a/tests/unit_tests/persistence/test_bot_plugin_processor_migration.py b/tests/unit_tests/persistence/test_bot_plugin_processor_migration.py index 4e3e4f0ad..b29f2152a 100644 --- a/tests/unit_tests/persistence/test_bot_plugin_processor_migration.py +++ b/tests/unit_tests/persistence/test_bot_plugin_processor_migration.py @@ -46,4 +46,29 @@ def test_migrate_duplicate_routes_and_preserve_primary_and_disabled(column_exist assert conn.execute(sa.select(migrated)).mappings().one() == row migration.downgrade() assert 'plugin_processors' not in {c['name'] for c in sa.inspect(conn).get_columns('bots')} + restored = sa.Table('bots', sa.MetaData(), autoload_with=conn) + bindings = conn.execute(sa.select(restored.c.event_bindings)).scalar_one() + assert bindings == [ + primary, + { + 'id': 'plugin_processor:one', + 'event_pattern': '*', + 'target_type': 'event_processor', + 'target_uuid': 'one', + 'enabled': True, + 'filters': [], + 'priority': 0, + 'order': 1, + }, + { + 'id': 'plugin_processor:two', + 'event_pattern': '*', + 'target_type': 'event_processor', + 'target_uuid': 'two', + 'enabled': False, + 'filters': [], + 'priority': 0, + 'order': 2, + }, + ] engine.dispose() diff --git a/tests/unit_tests/persistence/test_database_decorator.py b/tests/unit_tests/persistence/test_database_decorator.py index 8d66dbdbc..520ee47da 100644 --- a/tests/unit_tests/persistence/test_database_decorator.py +++ b/tests/unit_tests/persistence/test_database_decorator.py @@ -13,7 +13,7 @@ from __future__ import annotations import sys from contextlib import contextmanager from typing import Generator -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock import pytest @@ -174,40 +174,3 @@ class TestManagerClassDecorator: # BaseDatabaseManager has name annotation (type hint) # Check __annotations__ for the type hint assert 'name' in database.BaseDatabaseManager.__annotations__ - - def test_decorated_class_inherits_from_base(self): - """Test that decorated class properly inherits BaseDatabaseManager.""" - database = get_database_module() - - database.preregistered_managers.clear() - - @database.manager_class('test_inherit') - class TestChild(database.BaseDatabaseManager): - async def initialize(self): - pass - - assert issubclass(TestChild, database.BaseDatabaseManager) - # Has abstract method requirement satisfied - assert hasattr(TestChild, 'initialize') - - def test_decorator_preserves_class_methods(self): - """Test that decorator preserves existing class methods.""" - database = get_database_module() - - database.preregistered_managers.clear() - - @database.manager_class('preserve_test') - class ManagerWithMethods(database.BaseDatabaseManager): - custom_attr = 'test_value' - - async def initialize(self): - pass - - def custom_method(self): - return self.custom_attr - - assert ManagerWithMethods.custom_attr == 'test_value' - # Create instance to test method (with mock app) - mock_app = Mock() - instance = ManagerWithMethods(mock_app) - assert instance.custom_method() == 'test_value' diff --git a/tests/unit_tests/persistence/test_mgr_methods.py b/tests/unit_tests/persistence/test_mgr_methods.py index 0c75cf2d4..0182b7eea 100644 --- a/tests/unit_tests/persistence/test_mgr_methods.py +++ b/tests/unit_tests/persistence/test_mgr_methods.py @@ -50,8 +50,8 @@ class TestExecuteAsync: # Execute a simple select await mgr.execute_async(sqlalchemy.select(1)) - mock_conn.execute.assert_called_once() - mock_conn.commit.assert_called_once() + mock_conn.execute.assert_awaited_once() + mock_conn.commit.assert_awaited_once() @pytest.mark.asyncio async def test_execute_async_returns_result(self): diff --git a/tests/unit_tests/persistence/test_runner_state_migration.py b/tests/unit_tests/persistence/test_runner_state_migration.py index 82e12282a..f016d09b6 100644 --- a/tests/unit_tests/persistence/test_runner_state_migration.py +++ b/tests/unit_tests/persistence/test_runner_state_migration.py @@ -39,4 +39,6 @@ def test_two_populated_tables_fail_without_discarding_either(): with pytest.raises(RuntimeError, match='Both .* contain state'): migration.upgrade() assert set(sa.inspect(conn).get_table_names()) == {'agent_runner_state', 'runner_state'} + assert conn.execute(sa.text('SELECT id FROM agent_runner_state')).all() == [(1,)] + assert conn.execute(sa.text('SELECT id FROM runner_state')).all() == [(1,)] engine.dispose() diff --git a/tests/unit_tests/persistence/test_serialize_model.py b/tests/unit_tests/persistence/test_serialize_model.py index bbab59c67..e90b3d337 100644 --- a/tests/unit_tests/persistence/test_serialize_model.py +++ b/tests/unit_tests/persistence/test_serialize_model.py @@ -81,7 +81,7 @@ class TestSerializeModel: result = mgr.serialize_model(TestModel, instance) - assert '2024-01-15' in result['created_at'] + assert result['created_at'] == '2024-01-15T10:30:45+00:00' assert isinstance(result['created_at'], str) def test_serialize_none_datetime(self): diff --git a/tests/unit_tests/pipeline/test_aggregator.py b/tests/unit_tests/pipeline/test_aggregator.py index 24141b3ec..274c6b523 100644 --- a/tests/unit_tests/pipeline/test_aggregator.py +++ b/tests/unit_tests/pipeline/test_aggregator.py @@ -465,8 +465,19 @@ class TestMessageAggregatorAddMessage: pipeline_uuid=None, # None -> disabled ) - # Should have called query_pool.add_query - assert app.query_pool.add_query.called + app.query_pool.add_query.assert_awaited_once_with( + execution_context=execution_context(), + bot_uuid='test-bot', + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id=12345, + sender_id=12345, + message_event=event, + message_chain=chain, + adapter=adapter, + pipeline_uuid=None, + routed_by_rule=False, + variables=None, + ) @pytest.mark.asyncio async def test_enabled_buffers_message(self): @@ -505,8 +516,27 @@ class TestMessageAggregatorAddMessage: pipeline_uuid='test-pipeline', ) - # Should have buffered the message - assert len(agg.buffers) == 1 + context = execution_context(pipeline_uuid='test-pipeline') + key = aggregation_key(context, pipeline_uuid='test-pipeline') + assert list(agg.buffers) == [key] + assert agg.buffers[key].execution_context == context + assert len(agg.buffers[key].messages) == 1 + pending = agg.buffers[key].messages[0] + assert {key: value for key, value in vars(pending).items() if key != 'timestamp'} == { + 'execution_context': context, + 'bot_uuid': 'test-bot', + 'launcher_type': provider_session.LauncherTypes.PERSON, + 'launcher_id': 12345, + 'sender_id': 12345, + 'message_event': event, + 'message_chain': chain, + 'adapter': adapter, + 'pipeline_uuid': 'test-pipeline', + 'routed_by_rule': False, + 'variables': None, + } + app.query_pool.add_query.assert_not_awaited() + await agg.flush_all() @pytest.mark.asyncio async def test_control_variables_bypass_aggregation(self): @@ -560,19 +590,18 @@ class TestMessageAggregatorAddMessage: agg = aggregator.MessageAggregator(app) - chain = text_chain('hello') - event = friend_message_event(chain) adapter = mock_adapter() # Add messages up to MAX_BUFFER_MESSAGES for i in range(aggregator.MAX_BUFFER_MESSAGES): + chain = text_chain(f'message {i}') await agg.add_message( execution_context=execution_context(pipeline_uuid='test-pipeline'), bot_uuid='test-bot', launcher_type=provider_session.LauncherTypes.PERSON, launcher_id=12345, sender_id=12345, - message_event=event, + message_event=friend_message_event(chain), message_chain=chain, adapter=adapter, pipeline_uuid='test-pipeline', @@ -588,6 +617,14 @@ class TestMessageAggregatorAddMessage: 'test-pipeline', ) assert session_id not in agg.buffers or len(agg.buffers[session_id].messages) == 0 + app.query_pool.add_query.assert_awaited_once() + forwarded = app.query_pool.add_query.call_args.kwargs + assert str(forwarded['message_chain']) == '\n'.join( + f'message {i}' for i in range(aggregator.MAX_BUFFER_MESSAGES) + ) + assert forwarded['execution_context'] == context + assert forwarded['bot_uuid'] == 'test-bot' + assert forwarded['pipeline_uuid'] == 'test-pipeline' class TestMessageAggregatorMerge: @@ -658,10 +695,7 @@ class TestMessageAggregatorMerge: merged = agg._merge_messages([pending1, pending2]) - # Should contain both messages with separator - merged_str = str(merged.message_chain) - assert 'hello' in merged_str - assert 'world' in merged_str + assert str(merged.message_chain) == 'hello\nworld' def test_merge_messages_preserves_routed_by_rule_if_any_input_matches(self): """Merged PendingMessage should keep routed_by_rule when any input was rule-routed.""" @@ -760,7 +794,19 @@ class TestMessageAggregatorFlush: await agg._flush_buffer(key, context) - assert app.query_pool.add_query.called + app.query_pool.add_query.assert_awaited_once_with( + execution_context=context, + bot_uuid='test-bot', + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id=12345, + sender_id=12345, + message_event=event, + message_chain=chain, + adapter=adapter, + pipeline_uuid=None, + routed_by_rule=False, + variables=None, + ) assert key not in agg.buffers @pytest.mark.asyncio @@ -825,8 +871,8 @@ class TestMessageAggregatorFlushAll: app = make_aggregator_app() agg = aggregator.MessageAggregator(app) - chain = text_chain('hello') - event = friend_message_event(chain) + first_chain = text_chain('first') + second_chain = text_chain('second') adapter = mock_adapter() # Create two buffers @@ -836,8 +882,8 @@ class TestMessageAggregatorFlushAll: launcher_type=provider_session.LauncherTypes.PERSON, launcher_id=12345, sender_id=12345, - message_event=event, - message_chain=chain, + message_event=friend_message_event(first_chain), + message_chain=first_chain, adapter=adapter, pipeline_uuid=None, ) @@ -848,8 +894,8 @@ class TestMessageAggregatorFlushAll: launcher_type=provider_session.LauncherTypes.PERSON, launcher_id=67890, sender_id=67890, - message_event=event, - message_chain=chain, + message_event=friend_message_event(second_chain), + message_chain=second_chain, adapter=adapter, pipeline_uuid=None, ) @@ -875,7 +921,11 @@ class TestMessageAggregatorFlushAll: # Both buffers should be flushed assert len(agg.buffers) == 0 - assert app.query_pool.add_query.call_count == 2 + assert app.query_pool.add_query.await_count == 2 + assert [ + (sent.kwargs['launcher_id'], str(sent.kwargs['message_chain']), sent.kwargs['execution_context']) + for sent in app.query_pool.add_query.await_args_list + ] == [(12345, 'first', context), (67890, 'second', context)] class TestMessageAggregatorWorkspaceIsolation: diff --git a/tests/unit_tests/pipeline/test_chat_handler.py b/tests/unit_tests/pipeline/test_chat_handler.py index ce4be91af..ff39ad7ea 100644 --- a/tests/unit_tests/pipeline/test_chat_handler.py +++ b/tests/unit_tests/pipeline/test_chat_handler.py @@ -89,6 +89,25 @@ def set_runner(fake_app): return _set_runner +@pytest.fixture +def chat_session(): + from langbot_plugin.api.entities.builtin.provider.session import Session, Conversation, LauncherTypes + from langbot_plugin.api.entities.builtin.provider.prompt import Prompt + + return Session( + launcher_type=LauncherTypes.PERSON, + launcher_id=12345, + sender_id=12345, + bot_uuid='test-bot-uuid', + using_conversation=Conversation( + prompt=Prompt(name='default', messages=[]), + messages=[], + pipeline_uuid='test-pipeline-uuid', + bot_uuid='test-bot-uuid', + ), + ) + + # ============== CACHED LAZY IMPORTS ============== _chat_handler_module = None @@ -189,7 +208,7 @@ class TestChatMessageHandlerReal: async def test_user_message_alter_string(self, fake_app, mock_event_ctx, set_runner): """user_message_alter as string updates query.user_message.""" from tests.factories import text_query - from langbot_plugin.api.entities.builtin.provider.message import Message + from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement chat = get_chat_handler() @@ -200,7 +219,8 @@ class TestChatMessageHandlerReal: query = text_query('original') query.adapter = Mock() query.adapter.is_stream_output_supported = AsyncMock(return_value=False) - query.user_message = Message(role='user', content=[]) + query.user_message = Message(role='user', content=[ContentElement.from_text('original')]) + received_contents = [] class QuickRunner: name = 'local-agent' @@ -210,6 +230,7 @@ class TestChatMessageHandlerReal: self.config = config async def run(self, query): + received_contents.append(query.user_message.content) yield Message(role='assistant', content='ok') set_runner(QuickRunner) @@ -220,10 +241,14 @@ class TestChatMessageHandlerReal: async for result in handler.handle(query): results.append(result) - assert query.user_message.content is not None + expected_content = [ContentElement.from_text('altered text')] + assert query.user_message.content == expected_content + assert received_contents == [expected_content] @pytest.mark.asyncio - async def test_adapter_without_stream_method_defaults_non_stream(self, fake_app, mock_event_ctx, set_runner): + async def test_adapter_without_stream_method_defaults_non_stream( + self, fake_app, mock_event_ctx, set_runner, chat_session + ): """Adapter without is_stream_output_supported defaults to non-stream.""" from tests.factories import text_query from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement @@ -237,6 +262,7 @@ class TestChatMessageHandlerReal: query = text_query('test') query.adapter = Mock(spec=[]) query.user_message = Message(role='user', content=[ContentElement.from_text('test')]) + query.session = chat_session class SingleRunner: name = 'local-agent' @@ -256,7 +282,9 @@ class TestChatMessageHandlerReal: async for result in handler.handle(query): results.append(result) - assert len(results) >= 1 + assert len(results) == 1 + assert results[0].result_type == get_entities().ResultType.CONTINUE + assert [(message.role, message.content) for message in query.resp_messages] == [('assistant', 'response')] @pytest.mark.usefixtures('mock_circular_import_chain') @@ -315,7 +343,7 @@ class TestChatHandlerStreaming: query.adapter.create_message_card.assert_awaited_once() @pytest.mark.asyncio - async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner): + async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner, chat_session): """Streaming produces multiple results.""" from tests.factories import text_query from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement, MessageChunk @@ -330,6 +358,7 @@ class TestChatHandlerStreaming: query.adapter.is_stream_output_supported = AsyncMock(return_value=True) query.adapter.create_message_card = AsyncMock() query.user_message = Message(role='user', content=[ContentElement.from_text('test')]) + query.session = chat_session class StreamRunner: name = 'local-agent' @@ -340,17 +369,24 @@ class TestChatHandlerStreaming: async def run(self, query): yield MessageChunk(role='assistant', content='Hello', is_final=False) - yield MessageChunk(role='assistant', content=' World', is_final=True) + yield MessageChunk(role='assistant', content='Hello World', is_final=True) set_runner(StreamRunner) handler = chat.ChatMessageHandler(fake_app) results = [] + delivered = [] async for result in handler.handle(query): results.append(result) + delivered.append(query.resp_messages[-1]) - assert len(results) >= 1 + assert len(results) == 2 + assert all(result.result_type == get_entities().ResultType.CONTINUE for result in results) + assert [chunk.content for chunk in delivered] == ['Hello', 'Hello World'] + assert [chunk.is_final for chunk in delivered] == [False, True] + assert len({chunk.resp_message_id for chunk in delivered}) == 1 + query.adapter.create_message_card.assert_awaited_once() @pytest.mark.usefixtures('mock_circular_import_chain') @@ -536,7 +572,7 @@ class TestChatHandlerHelper: chat = get_chat_handler() handler = chat.ChatMessageHandler(fake_app) result = handler.cut_str('first line\nsecond line') - assert '...' in result + assert result == 'first line...' def test_response_size_limit_uses_instance_config(self, fake_app): from langbot_plugin.api.entities.builtin.provider.message import Message diff --git a/tests/unit_tests/pipeline/test_command_handler.py b/tests/unit_tests/pipeline/test_command_handler.py index bc5d39481..2f105a384 100644 --- a/tests/unit_tests/pipeline/test_command_handler.py +++ b/tests/unit_tests/pipeline/test_command_handler.py @@ -367,6 +367,8 @@ class TestCommandHandlerReal: assert len(msg.content) == 2 assert msg.content[0].type == 'text' assert msg.content[1].type == 'image_url' + assert msg.content[0].text == 'Here is the image:' + assert msg.content[1].image_url.url == 'https://example.com/image.png' @pytest.mark.asyncio async def test_command_result_empty_interrupts(self, fake_app, mock_event_ctx, mock_execute_factory): @@ -410,4 +412,4 @@ class TestCommandHandlerHelper: command = get_command_handler() handler = command.CommandHandler(fake_app) result = handler.cut_str('first line\nsecond line') - assert '...' in result + assert result == 'first line...' diff --git a/tests/unit_tests/pipeline/test_longtext.py b/tests/unit_tests/pipeline/test_longtext.py index 3a693276f..19f14aacd 100644 --- a/tests/unit_tests/pipeline/test_longtext.py +++ b/tests/unit_tests/pipeline/test_longtext.py @@ -13,6 +13,7 @@ from __future__ import annotations import pytest from unittest.mock import AsyncMock, Mock from importlib import import_module +from types import SimpleNamespace from tests.factories import ( FakeApp, @@ -73,7 +74,7 @@ class TestLongTextProcessStageInit: async def test_initialize_forward_strategy(self): """Initialize with strategy='forward' should use ForwardComponentStrategy.""" longtext = get_longtext_module() - strategy = get_strategy_module() + forward = import_module('langbot.pkg.pipeline.longtext.strategies.forward') app = FakeApp() stage = longtext.LongTextProcessStage(app) @@ -82,8 +83,7 @@ class TestLongTextProcessStageInit: await stage.initialize(pipeline_config) - assert stage.strategy_impl is not None - assert isinstance(stage.strategy_impl, strategy.LongTextStrategy) + assert isinstance(stage.strategy_impl, forward.ForwardComponentStrategy) @pytest.mark.asyncio async def test_initialize_unknown_strategy_raises(self): @@ -340,10 +340,11 @@ class TestLongTextProcessStageImageStrategy: """Tests for image strategy handling (requires PIL/font).""" @pytest.mark.asyncio - async def test_image_strategy_missing_font_fallback(self): + async def test_image_strategy_missing_font_fallback(self, monkeypatch): """Missing font should fallback to forward strategy.""" longtext = get_longtext_module() - strategy = get_strategy_module() + forward = import_module('langbot.pkg.pipeline.longtext.strategies.forward') + monkeypatch.setattr(longtext, 'os', SimpleNamespace(name='posix', path=SimpleNamespace(exists=lambda _: False))) app = FakeApp() stage = longtext.LongTextProcessStage(app) @@ -354,6 +355,5 @@ class TestLongTextProcessStageImageStrategy: # On non-Windows without font, should fallback to forward await stage.initialize(pipeline_config) - # Should have initialized (possibly with fallback strategy) - if stage.strategy_impl is not None: - assert isinstance(stage.strategy_impl, strategy.LongTextStrategy) + assert pipeline_config['output']['long-text-processing']['strategy'] == 'forward' + assert isinstance(stage.strategy_impl, forward.ForwardComponentStrategy) diff --git a/tests/unit_tests/pipeline/test_preproc.py b/tests/unit_tests/pipeline/test_preproc.py index 5ab2cafc6..1b64e2ca3 100644 --- a/tests/unit_tests/pipeline/test_preproc.py +++ b/tests/unit_tests/pipeline/test_preproc.py @@ -190,6 +190,7 @@ class TestPreProcessorNormalText: assert result.new_query.user_message is not None assert result.new_query.user_message.role == 'user' + assert [(part.type, part.text) for part in result.new_query.user_message.content] == [('text', 'test message')] class TestPreProcessorEmptyMessage: @@ -254,6 +255,7 @@ class TestPreProcessorImageSegment: mock_model = Mock() mock_model.model_entity = Mock(uuid='vision-model', abilities=['func_call', 'vision']) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) + attach_runner_descriptor(app) app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) mock_event_ctx = Mock() @@ -261,9 +263,8 @@ class TestPreProcessorImageSegment: app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) stage = preproc.PreProcessor(app) - # Image query with base64 + # Exercise both supported image transports. query = image_query(text='look at this', url=None) - # Set base64 on the image component import langbot_plugin.api.entities.builtin.platform.message as platform_message chain = platform_message.MessageChain( @@ -283,17 +284,23 @@ class TestPreProcessorImageSegment: result = await stage.process(query, 'PreProcessor') assert result.result_type == preproc.entities.ResultType.CONTINUE - # User message should have content - assert result.new_query.user_message.content is not None + assert result.new_query.use_llm_model_uuid == 'vision-model' from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter + from langbot_plugin.api.entities.builtin.provider.message import ContentElement + expected_image = ( + ContentElement.from_image_base64('data:image/png;base64,abc123') + if image_source == 'base64' + else ContentElement.from_image_url('https://cdn.example/image.png') + ) + assert result.new_query.user_message.content == [ContentElement.from_text('look at this'), expected_image] contents = [part.model_dump() for part in result.new_query.user_message.content] attachments = QueryEntryAdapter._build_attachments(result.new_query, contents) assert len(attachments) == 1 if image_source == 'url': assert attachments[0]['url'] == 'https://cdn.example/image.png' else: - assert attachments[0].get('content') or attachments[0].get('url') + assert attachments[0]['content'] == 'data:image/png;base64,abc123' @pytest.mark.asyncio async def test_image_without_vision_model(self): @@ -315,6 +322,7 @@ class TestPreProcessorImageSegment: mock_model = Mock() mock_model.model_entity = Mock(uuid='text-only-model', abilities=['func_call']) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) + attach_runner_descriptor(app) app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) mock_event_ctx = Mock() @@ -327,6 +335,12 @@ class TestPreProcessorImageSegment: result = await stage.process(query, 'PreProcessor') assert result.result_type == preproc.entities.ResultType.CONTINUE + assert result.new_query.use_llm_model_uuid == 'text-only-model' + assert [(part.type, part.text) for part in result.new_query.user_message.content] == [ + ('text', 'describe this'), + ('text', '[Image]'), + ] + assert result.new_query.variables['user_message_text'] == 'describe this[Image]' class TestPreProcessorModelSelection: @@ -471,11 +485,18 @@ class TestPreProcessorVariables: result = await stage.process(query, 'PreProcessor') variables = result.new_query.variables - assert 'launcher_type' in variables - assert 'launcher_id' in variables - assert 'sender_id' in variables - assert variables['sender_id'] == 67890 - assert 'user_message_text' in variables + expected = { + 'launcher_type': 'person', + 'launcher_id': 12345, + 'sender_id': 67890, + 'session_id': 'person_12345', + 'conversation_id': 'conv-123', + 'msg_create_time': 1609459200, + 'group_name': '', + 'sender_name': 'TestUser', + 'user_message_text': 'hello', + } + assert {key: variables[key] for key in expected} == expected @pytest.mark.asyncio async def test_group_variables_include_group_name(self): @@ -509,8 +530,8 @@ class TestPreProcessorVariables: result = await stage.process(query, 'PreProcessor') variables = result.new_query.variables - assert 'group_name' in variables - assert 'sender_name' in variables + assert variables['group_name'] == 'TestGroup' + assert variables['sender_name'] == 'TestUser' class TestPreProcessorToolSelection: diff --git a/tests/unit_tests/pipeline/test_ratelimit.py b/tests/unit_tests/pipeline/test_ratelimit.py index e23636ab7..b4485c765 100644 --- a/tests/unit_tests/pipeline/test_ratelimit.py +++ b/tests/unit_tests/pipeline/test_ratelimit.py @@ -272,11 +272,14 @@ class TestFixedWindowAlgo: algo = fixedwin.FixedWindowAlgo(mock_app_for_algo) await algo.initialize() - # release_access is empty in current implementation + await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345') + containers = dict(algo.containers) + records = {key: dict(container.records) for key, container in containers.items()} + await algo.release_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345') - # Should not raise or change state - assert 'person_12345' not in algo.containers + assert algo.containers == containers + assert {key: dict(container.records) for key, container in algo.containers.items()} == records # Original mock-based tests for RateLimit stage integration diff --git a/tests/unit_tests/pipeline/test_wrapper.py b/tests/unit_tests/pipeline/test_wrapper.py index 1dd9efbb8..38e014321 100644 --- a/tests/unit_tests/pipeline/test_wrapper.py +++ b/tests/unit_tests/pipeline/test_wrapper.py @@ -22,6 +22,7 @@ from tests.factories import ( import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.provider.session as provider_session +from langbot_plugin.api.entities.builtin.provider.message import Message def get_wrapper_module(): @@ -109,7 +110,7 @@ class TestResponseWrapperMessageChain: assert len(results) == 1 assert results[0].result_type == entities.ResultType.CONTINUE - assert len(results[0].new_query.resp_message_chain) == 1 + assert [str(chain) for chain in results[0].new_query.resp_message_chain] == ['response'] @pytest.mark.asyncio async def test_message_chain_direct_append_consumes_pending_plugin_source(self): @@ -171,12 +172,7 @@ class TestResponseWrapperCommand: query.pipeline_config = pipeline_config query.resp_message_chain = [] - # Create a command response message - command_resp = Mock() - command_resp.role = 'command' - command_resp.get_content_platform_message_chain = Mock( - return_value=platform_message.MessageChain([platform_message.Plain(text='Help info')]) - ) + command_resp = Message(role='command', content='Help info') query.resp_messages = [command_resp] results = [] @@ -185,8 +181,7 @@ class TestResponseWrapperCommand: assert len(results) == 1 assert results[0].result_type == entities.ResultType.CONTINUE - # Check that prefix was added (via get_content_platform_message_chain) - command_resp.get_content_platform_message_chain.assert_called_once() + assert [str(chain) for chain in query.resp_message_chain] == ['[bot] Help info'] class TestResponseWrapperPlugin: @@ -223,6 +218,7 @@ class TestResponseWrapperPlugin: assert len(results) == 1 assert results[0].result_type == entities.ResultType.CONTINUE + assert [str(chain) for chain in query.resp_message_chain] == ['Plugin response'] class TestResponseWrapperAssistant: @@ -316,8 +312,11 @@ class TestResponseWrapperAssistant: assert len(results) == 1 assert results[0].result_type == entities.ResultType.CONTINUE - # Event should have been emitted - app.plugin_connector.emit_event.assert_called() + assert [str(chain) for chain in query.resp_message_chain] == ['Hello back!'] + app.plugin_connector.emit_event.assert_awaited_once() + event = app.plugin_connector.emit_event.await_args.args[0] + assert event.response_text == 'Hello back!' + assert event.funcs_called == [] @pytest.mark.asyncio async def test_assistant_empty_content(self): @@ -386,24 +385,36 @@ class TestResponseWrapperAssistant: mock_tool_call = Mock() mock_tool_call.function = Mock() mock_tool_call.function.name = 'test_function' + other_tool_call = Mock() + other_tool_call.function.name = 'other_function' assistant_resp = Mock() assistant_resp.role = 'assistant' assistant_resp.content = 'Processing...' - assistant_resp.tool_calls = [mock_tool_call] + assistant_resp.tool_calls = [mock_tool_call, other_tool_call] assistant_resp.get_content_platform_message_chain = Mock( return_value=platform_message.MessageChain([platform_message.Plain(text='Processing...')]) ) query.resp_messages = [assistant_resp] results = [] + delivered = [] async for result in stage.process(query, 'ResponseWrapper'): results.append(result) + delivered.append(str(query.resp_message_chain[-1])) assert len(results) == 2 for result in results: assert result.result_type == entities.ResultType.CONTINUE assert app.plugin_connector.emit_event.await_count == 2 + expected = ['Processing...', 'Calling test_function, other_function...'] + assert delivered == expected + events = [sent.args[0] for sent in app.plugin_connector.emit_event.await_args_list] + assert [event.response_text for event in events] == expected + assert [event.funcs_called for event in events] == [ + ['test_function', 'other_function'], + ['test_function', 'other_function'], + ] class TestResponseWrapperInterrupt: diff --git a/tests/unit_tests/platform/test_botmgr_execution_trace.py b/tests/unit_tests/platform/test_botmgr_execution_trace.py index d0b2b27e0..f3a523b0d 100644 --- a/tests/unit_tests/platform/test_botmgr_execution_trace.py +++ b/tests/unit_tests/platform/test_botmgr_execution_trace.py @@ -613,6 +613,7 @@ def test_agent_event_keeps_image_file_and_voice_attachments(): ) agent_input = RuntimeBot._build_agent_input(event) assert [attachment.type for attachment in agent_input.attachments] == ['image', 'file', 'voice'] + assert agent_input.attachments[0].url == 'https://cdn.example.test/image.png' assert agent_input.attachments[1].name == 'input.txt' assert agent_input.attachments[1].url == 'https://cdn.example.test/input.txt' assert agent_input.attachments[2].content == 'dGVzdA==' diff --git a/tests/unit_tests/platform/test_discord_eba_adapter.py b/tests/unit_tests/platform/test_discord_eba_adapter.py index 50521c55a..0e776bf1a 100644 --- a/tests/unit_tests/platform/test_discord_eba_adapter.py +++ b/tests/unit_tests/platform/test_discord_eba_adapter.py @@ -3,7 +3,7 @@ from __future__ import annotations import pathlib import datetime from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest import yaml @@ -401,8 +401,7 @@ async def test_discord_stream_keeps_tool_round_and_delivers_terminal_attachment( assert 'stream' in adapter._stream_buffer adapter.reply_message.assert_not_awaited() await adapter.reply_message_chunk(source, {'resp_message_id': 'stream'}, message, is_final=True) - adapter.reply_message.assert_awaited_once() - assert adapter.reply_message.await_args.args[1].root == [attachment] + adapter.reply_message.assert_awaited_once_with(source, message, False) channel.send.assert_not_awaited() assert not adapter._stream_buffer @@ -471,26 +470,34 @@ async def test_discord_uncached_edit_fetches_message_without_duplicate_or_previe @pytest.mark.asyncio -async def test_guild_event_reply_uses_system_channel(monkeypatch): +@pytest.mark.parametrize(('guild_id', 'expected_guild_id'), [('123', 123), ('456', 456)]) +async def test_guild_event_reply_uses_system_channel(monkeypatch, guild_id, expected_guild_id): adapter = make_adapter() channel = SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=456))) - monkeypatch.setattr(adapter.bot, 'get_guild', lambda guild_id: SimpleNamespace(system_channel=channel)) + get_guild = Mock(return_value=SimpleNamespace(system_channel=channel)) + monkeypatch.setattr(adapter.bot, 'get_guild', get_guild) adapter._get_channel = AsyncMock() result = await adapter.send_message( - 'group', '123', platform_message.MessageChain([platform_message.Plain(text='Welcome')]) + 'group', guild_id, platform_message.MessageChain([platform_message.Plain(text='Welcome')]) ) assert result.message_id == 456 + get_guild.assert_called_once_with(expected_guild_id) channel.send.assert_awaited_once_with(content='Welcome') adapter._get_channel.assert_not_awaited() @pytest.mark.asyncio -async def test_guild_event_reply_without_system_channel_does_not_pick_another_channel(monkeypatch): +@pytest.mark.parametrize(('guild_id', 'expected_guild_id'), [('123', 123), ('456', 456)]) +async def test_guild_event_reply_without_system_channel_does_not_pick_another_channel( + monkeypatch, guild_id, expected_guild_id +): adapter = make_adapter() - monkeypatch.setattr(adapter.bot, 'get_guild', lambda guild_id: SimpleNamespace(system_channel=None)) + get_guild = Mock(return_value=SimpleNamespace(system_channel=None)) + monkeypatch.setattr(adapter.bot, 'get_guild', get_guild) adapter._get_channel = AsyncMock() with pytest.raises(ValueError, match='no system channel'): await adapter.send_message( - 'group', '123', platform_message.MessageChain([platform_message.Plain(text='Welcome')]) + 'group', guild_id, platform_message.MessageChain([platform_message.Plain(text='Welcome')]) ) + get_guild.assert_called_once_with(expected_guild_id) adapter._get_channel.assert_not_awaited() diff --git a/tests/unit_tests/platform/test_officialaccount_eba_adapter.py b/tests/unit_tests/platform/test_officialaccount_eba_adapter.py index f6d55aa4a..65c812139 100644 --- a/tests/unit_tests/platform/test_officialaccount_eba_adapter.py +++ b/tests/unit_tests/platform/test_officialaccount_eba_adapter.py @@ -137,10 +137,7 @@ async def test_officialaccount_message_converter_maps_components_to_passive_text ) ) - assert 'hi' in content - assert '[Image]' in content - assert '[File: a.txt]' in content - assert 'quoted' in content + assert content == 'hi\n[Image]\n[File: a.txt]\nquoted' @pytest.mark.asyncio diff --git a/tests/unit_tests/platform/test_openclaw_weixin_client.py b/tests/unit_tests/platform/test_openclaw_weixin_client.py index 6c8dcadc5..11f0779ab 100644 --- a/tests/unit_tests/platform/test_openclaw_weixin_client.py +++ b/tests/unit_tests/platform/test_openclaw_weixin_client.py @@ -11,12 +11,20 @@ from langbot.libs.openclaw_weixin_api.client import ( from langbot.libs.openclaw_weixin_api.types import ApiError -def test_cdn_crypto_helpers_round_trip(): - original = b'tenant-media' * 128 +def test_cdn_crypto_helpers_round_trip(monkeypatch): + # AES-128-ECB with PKCS7 padding, independently generated using OpenSSL. + key = bytes.fromhex('2b7e151628aed2a6abf7158809cf4f3c') + original = bytes.fromhex('6bc1bee22e409f96e93d7e117393172a') + expected_ciphertext = bytes.fromhex('3ad77bb40d7a3660a89ecaf32466ef97a254be88e037ddd9d79fb6411c3f9df8') + monkeypatch.setattr('langbot.libs.openclaw_weixin_api.client.os.urandom', lambda size: key) - aes_key_hex, _encoded_key, encrypted, _raw_md5 = _encrypt_cdn_payload(original) + aes_key_hex, encoded_key, encrypted, raw_md5 = _encrypt_cdn_payload(original) - assert _decrypt_cdn_payload(encrypted, bytes.fromhex(aes_key_hex)) == original + assert aes_key_hex == '2b7e151628aed2a6abf7158809cf4f3c' + assert encoded_key == 'MmI3ZTE1MTYyOGFlZDJhNmFiZjcxNTg4MDljZjRmM2M=' + assert encrypted == expected_ciphertext + assert raw_md5 == 'f2ecfb66e72f88f217e659ac75b1075f' + assert _decrypt_cdn_payload(expected_ciphertext, key) == original @pytest.mark.asyncio diff --git a/tests/unit_tests/platform/test_slack_eba_adapter.py b/tests/unit_tests/platform/test_slack_eba_adapter.py index 8c6e7decd..faa1816b2 100644 --- a/tests/unit_tests/platform/test_slack_eba_adapter.py +++ b/tests/unit_tests/platform/test_slack_eba_adapter.py @@ -164,12 +164,7 @@ async def test_slack_message_converter_maps_common_components_to_text(): ) ) - assert 'hi' in text - assert '<@U-2>' in text - assert '' in text - assert 'https://example.test/a.png' in text - assert 'a.txt' in text - assert 'quoted' in text + assert text == 'hi\n<@U-2>\n\nhttps://example.test/a.png\na.txt\nquoted' @pytest.mark.asyncio diff --git a/tests/unit_tests/platform/test_telegram_eba_adapter.py b/tests/unit_tests/platform/test_telegram_eba_adapter.py index eca17ca4a..24761fa8e 100644 --- a/tests/unit_tests/platform/test_telegram_eba_adapter.py +++ b/tests/unit_tests/platform/test_telegram_eba_adapter.py @@ -604,6 +604,17 @@ async def test_telegram_platform_apis_call_underlying_bot_methods(): assert invite['invite_link'] == 'https://t.me/+abc' assert await PLATFORM_API_MAP['answer_callback_query'](bot, {'callback_query_id': 'cb'}) == {'ok': True} + bot.pin_chat_message.assert_awaited_once_with(chat_id=1, message_id=2, disable_notification=False) + bot.unpin_chat_message.assert_awaited_once_with(chat_id=1, message_id=2) + bot.unpin_all_chat_messages.assert_awaited_once_with(chat_id=1) + bot.get_chat_administrators.assert_awaited_once_with(chat_id=1) + bot.set_chat_title.assert_awaited_once_with(chat_id=1, title='New') + bot.set_chat_description.assert_awaited_once_with(chat_id=1, description='Desc') + bot.get_chat_member_count.assert_awaited_once_with(chat_id=1) + bot.send_chat_action.assert_awaited_once_with(chat_id=1, action='typing') + bot.create_chat_invite_link.assert_awaited_once_with(chat_id=1, name='invite', expire_date=None, member_limit=None) + bot.answer_callback_query.assert_awaited_once_with(callback_query_id='cb', text=None, show_alert=False, url=None) + @pytest.mark.asyncio async def test_telegram_unmute_member_uses_current_chat_permissions_fields(): diff --git a/tests/unit_tests/platform/test_websocket_adapter_attachments.py b/tests/unit_tests/platform/test_websocket_adapter_attachments.py index 3274626bf..9504ca251 100644 --- a/tests/unit_tests/platform/test_websocket_adapter_attachments.py +++ b/tests/unit_tests/platform/test_websocket_adapter_attachments.py @@ -90,17 +90,23 @@ async def test_image_defaults_to_png(): adapter, _, _ = _make_adapter() chain = [{'type': 'Image', 'path': f'{_UPLOAD_PREFIX}blob'}] await adapter._process_image_components(_make_connection(), chain) - assert chain[0]['base64'].startswith('data:image/png;base64,') + assert chain[0]['base64'] == 'data:image/png;base64,aGVsbG8=' @pytest.mark.asyncio -async def test_voice_uses_guessed_or_wav_mimetype(): +async def test_voice_uses_guessed_or_wav_mimetype(monkeypatch): adapter, storage_mgr, _ = _make_adapter() - chain = [{'type': 'Voice', 'path': f'{_UPLOAD_PREFIX}clip.wav'}] + path = f'{_UPLOAD_PREFIX}clip.unknown' + chain = [{'type': 'Voice', 'path': path}] + guess_type = Mock(return_value=(None, None)) + monkeypatch.setattr('mimetypes.guess_type', guess_type) + await adapter._process_image_components(_make_connection(), chain) - assert chain[0]['base64'].startswith('data:audio/') + + guess_type.assert_called_once_with(path) + assert chain[0]['base64'] == 'data:audio/wav;base64,aGVsbG8=' assert chain[0]['path'] == '' - storage_mgr.delete_scoped_object_key.assert_awaited_once() + storage_mgr.delete_scoped_object_key.assert_awaited_once_with(_CONTEXT, path, expected_owner_type='upload_image') @pytest.mark.asyncio diff --git a/tests/unit_tests/plugin/conftest.py b/tests/unit_tests/plugin/conftest.py new file mode 100644 index 000000000..3285bb2a0 --- /dev/null +++ b/tests/unit_tests/plugin/conftest.py @@ -0,0 +1,38 @@ +"""Database fixture for plugin persistence behavior tests.""" + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.bstorage import BinaryStorage +from langbot.pkg.entity.persistence.plugin import PluginSetting +from langbot.pkg.entity.persistence.user import User +from langbot.pkg.entity.persistence.workspace import Workspace + + +@pytest.fixture +async def plugin_db(): + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + try: + async with engine.begin() as connection: + await connection.execute(text('PRAGMA foreign_keys=ON')) + await connection.run_sync( + Base.metadata.create_all, + tables=[User.__table__, Workspace.__table__, PluginSetting.__table__, BinaryStorage.__table__], + ) + async with AsyncSession(engine, expire_on_commit=False) as session: + session.add_all( + Workspace( + uuid=workspace, + instance_uuid='instance-a', + name=workspace, + slug=workspace, + source='cloud_projection', + ) + for workspace in ('workspace-a', 'workspace-b') + ) + await session.flush() + yield session + finally: + await engine.dispose() diff --git a/tests/unit_tests/plugin/test_connector_pure.py b/tests/unit_tests/plugin/test_connector_pure.py index 337ec16a1..5f671fc8d 100644 --- a/tests/unit_tests/plugin/test_connector_pure.py +++ b/tests/unit_tests/plugin/test_connector_pure.py @@ -123,6 +123,7 @@ class TestExtractDepsMetadata: # Should find requirements.txt in subdirectory assert task_context.metadata['deps_total'] == 2 + assert task_context.metadata['deps_list'] == ['pytest', 'black'] def test_archive_preview_rejects_extreme_compression_ratio(self): from langbot.pkg.plugin.connector import inspect_plugin_archive_metadata @@ -147,13 +148,6 @@ class TestParsePluginId: assert author == 'myauthor' assert name == 'myplugin' - def test_parse_plugin_id_empty(self): - """Empty plugin ID is invalid.""" - from langbot.pkg.plugin.connector import PluginRuntimeConnector - - with pytest.raises(ValueError): - PluginRuntimeConnector._parse_plugin_id('') - @pytest.mark.asyncio async def test_marketplace_response_reader_is_bounded(): diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py index 0b54f9384..bfb7c2431 100644 --- a/tests/unit_tests/plugin/test_connector_reconcile.py +++ b/tests/unit_tests/plugin/test_connector_reconcile.py @@ -583,11 +583,15 @@ async def test_local_install_cleans_untracked_legacy_plugin_before_runtime_apply @pytest.mark.asyncio -@pytest.mark.parametrize(('remaining_references', 'statement_count'), [(1, 1), (0, 2)]) +@pytest.mark.parametrize('remaining_references', [1, 0]) async def test_artifact_cleanup_is_reference_counted_within_workspace( remaining_references: int, - statement_count: int, + plugin_db, ): + import sqlalchemy + from langbot.pkg.entity.persistence.plugin import PluginSetting + from langbot.pkg.entity.persistence.bstorage import BinaryStorage + app = SimpleNamespace( instance_config=SimpleNamespace(data={'plugin': {'enable': True}}), deployment=SimpleNamespace(mode='cloud'), @@ -598,19 +602,38 @@ async def test_artifact_cleanup_is_reference_counted_within_workspace( workspace_uuid='workspace-a', placement_generation=1, ) - statements = [] - - async def execute(statement): - statements.append(statement) - return SimpleNamespace(scalar_one=lambda: remaining_references) + digest = 'a' * 64 + settings = [('workspace-b', digest), ('workspace-a', 'b' * 64)] + if remaining_references: + settings.append(('workspace-a', digest)) + plugin_db.add_all( + PluginSetting(workspace_uuid=workspace, plugin_author='author', plugin_name=artifact, artifact_digest=artifact) + for workspace, artifact in settings + ) + plugin_db.add_all( + BinaryStorage( + workspace_uuid=workspace, unique_key=key, key=key, owner_type=owner_type, owner=owner, value=b'pkg' + ) + for workspace, key, owner_type, owner in [ + ('workspace-a', 'target', 'plugin_artifact', digest), + ('workspace-b', 'other-workspace', 'plugin_artifact', digest), + ('workspace-a', 'other-artifact', 'plugin_artifact', 'b' * 64), + ('workspace-a', 'other-owner-type', 'plugin', digest), + ] + ) + await plugin_db.flush() await connector._delete_artifact_if_unreferenced( execution_context, - 'a' * 64, - execute=execute, + digest, + execute=plugin_db.execute, ) - assert len(statements) == statement_count + remaining = (await plugin_db.execute(sqlalchemy.select(BinaryStorage.unique_key))).scalars().all() + expected = {'other-workspace', 'other-artifact', 'other-owner-type'} + if remaining_references: + expected.add('target') + assert set(remaining) == expected @pytest.mark.asyncio diff --git a/tests/unit_tests/plugin/test_extract_deps.py b/tests/unit_tests/plugin/test_extract_deps.py index 0980f4cc3..0753e6b3e 100644 --- a/tests/unit_tests/plugin/test_extract_deps.py +++ b/tests/unit_tests/plugin/test_extract_deps.py @@ -113,6 +113,7 @@ numpy""" # Should find nested requirements.txt (ends with 'requirements.txt') assert task_context.metadata.get('deps_total') == 2 + assert task_context.metadata['deps_list'] == ['requests', 'flask'] def test_no_requirements_in_zip(self): """Test handling ZIP without requirements.txt.""" diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py index 7f0c10ee5..8fd068745 100644 --- a/tests/unit_tests/plugin/test_handler_actions.py +++ b/tests/unit_tests/plugin/test_handler_actions.py @@ -236,10 +236,16 @@ class TestInitializePluginSettings: app.persistence_mgr.execute_async.assert_awaited_once() @pytest.mark.asyncio - async def test_existing_desired_setting_remains_core_owned(self, app): + async def test_existing_desired_setting_remains_core_owned(self, app, plugin_db): """Runtime initialization validates identity without rewriting Core state.""" + import sqlalchemy + from langbot.pkg.entity.persistence.plugin import PluginSetting + runtime_handler = make_handler(app) - existing_setting = SimpleNamespace( + existing_setting = PluginSetting( + workspace_uuid='workspace-a', + plugin_author='test-author', + plugin_name='test-plugin', enabled=False, priority=5, config={'key': 'value'}, @@ -247,7 +253,29 @@ class TestInitializePluginSettings: runtime_revision=1, artifact_digest='a' * 64, ) - app.persistence_mgr.execute_async.return_value = make_result(existing_setting) + other_setting = PluginSetting( + workspace_uuid='workspace-b', + plugin_author='test-author', + plugin_name='test-plugin', + enabled=True, + ) + plugin_db.add_all([other_setting, existing_setting]) + await plugin_db.flush() + + async def execute(statement): + result = await plugin_db.execute(statement) + return result.scalars() if statement.is_select or statement.is_update else result + + app.persistence_mgr.execute_async.side_effect = execute + before = ( + ( + await plugin_db.execute( + sqlalchemy.select(*PluginSetting.__table__.c).order_by(PluginSetting.workspace_uuid) + ) + ) + .mappings() + .all() + ) response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]( { @@ -260,6 +288,17 @@ class TestInitializePluginSettings: assert response.code == 0 app.persistence_mgr.execute_async.assert_awaited_once() + plugin_db.expire_all() + after = ( + ( + await plugin_db.execute( + sqlalchemy.select(*PluginSetting.__table__.c).order_by(PluginSetting.workspace_uuid) + ) + ) + .mappings() + .all() + ) + assert after == before class TestSetBinaryStorage: diff --git a/tests/unit_tests/plugin/test_handler_helpers.py b/tests/unit_tests/plugin/test_handler_helpers.py index b126a9b18..145d6e5bd 100644 --- a/tests/unit_tests/plugin/test_handler_helpers.py +++ b/tests/unit_tests/plugin/test_handler_helpers.py @@ -107,41 +107,69 @@ class TestMakeRagErrorResponse: class TestCleanupPluginData: """Tests for cleanup_plugin_data method.""" - @pytest.mark.asyncio - async def test_deletes_plugin_settings(self): - """Test that plugin settings are deleted.""" - handler_module = get_handler_module() + @pytest.fixture + async def cleanup(self, plugin_db): + from langbot.pkg.entity.persistence.plugin import PluginSetting + from langbot.pkg.entity.persistence.bstorage import BinaryStorage + from langbot_plugin.entities.io.context import InstallationBinding - mock_app = Mock() - mock_app.persistence_mgr = AsyncMock() - mock_app.persistence_mgr.execute_async = AsyncMock() - - # Mock the handler without connection (we only need ap) - handler_instance = Mock(spec=handler_module.RuntimeConnectionHandler) - handler_instance.ap = mock_app - - # Call cleanup_plugin_data - await handler_module.RuntimeConnectionHandler.cleanup_plugin_data( - handler_instance, 'test-author', 'test-plugin' + plugin_db.add_all( + PluginSetting(workspace_uuid=workspace, plugin_author=author, plugin_name=name) + for workspace, author, name in [ + ('workspace-a', 'test-author', 'test-plugin'), + ('workspace-b', 'test-author', 'test-plugin'), + ('workspace-a', 'other-author', 'test-plugin'), + ('workspace-a', 'test-author', 'other-plugin'), + ] ) - - # Verify plugin settings delete was called - calls = mock_app.persistence_mgr.execute_async.call_args_list - assert len(calls) >= 1 + plugin_db.add_all( + BinaryStorage( + workspace_uuid=workspace, unique_key=key, key=key, owner_type=owner_type, owner=owner, value=b'data' + ) + for workspace, key, owner_type, owner in [ + ('workspace-a', 'target', 'plugin', 'test-author/test-plugin'), + ('workspace-b', 'other-workspace', 'plugin', 'test-author/test-plugin'), + ('workspace-a', 'other-owner', 'plugin', 'other-author/test-plugin'), + ('workspace-a', 'other-type', 'skill', 'test-author/test-plugin'), + ] + ) + await plugin_db.flush() + app = Mock() + app.persistence_mgr.execute_async = plugin_db.execute + handler = get_handler_module().RuntimeConnectionHandler(Mock(), AsyncMock(), app) + binding = InstallationBinding( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=1, + installation_uuid='00000000-0000-4000-8000-000000000001', + runtime_revision=1, + artifact_digest='a' * 64, + ) + with handler.installation_scope(binding): + await handler.cleanup_plugin_data('test-author', 'test-plugin') + return plugin_db @pytest.mark.asyncio - async def test_deletes_binary_storage(self): + async def test_deletes_plugin_settings(self, cleanup): + import sqlalchemy + from langbot.pkg.entity.persistence.plugin import PluginSetting + + rows = ( + await cleanup.execute( + sqlalchemy.select(PluginSetting.workspace_uuid, PluginSetting.plugin_author, PluginSetting.plugin_name) + ) + ).all() + assert set(rows) == { + ('workspace-b', 'test-author', 'test-plugin'), + ('workspace-a', 'other-author', 'test-plugin'), + ('workspace-a', 'test-author', 'other-plugin'), + } + + @pytest.mark.asyncio + async def test_deletes_binary_storage(self, cleanup): """Test that binary storage is deleted.""" - handler_module = get_handler_module() + import sqlalchemy + from langbot.pkg.entity.persistence.bstorage import BinaryStorage - mock_app = Mock() - mock_app.persistence_mgr = AsyncMock() - mock_app.persistence_mgr.execute_async = AsyncMock() - - handler_instance = Mock(spec=handler_module.RuntimeConnectionHandler) - handler_instance.ap = mock_app - - await handler_module.RuntimeConnectionHandler.cleanup_plugin_data(handler_instance, 'author', 'plugin-name') - - # Should have at least 2 calls: one for settings, one for binary storage - assert mock_app.persistence_mgr.execute_async.call_count >= 2 + keys = (await cleanup.execute(sqlalchemy.select(BinaryStorage.unique_key))).scalars().all() + assert set(keys) == {'other-workspace', 'other-owner', 'other-type'} diff --git a/tests/unit_tests/plugin/test_plugin_id_parsing.py b/tests/unit_tests/plugin/test_plugin_id_parsing.py index f76463785..810eb85f9 100644 --- a/tests/unit_tests/plugin/test_plugin_id_parsing.py +++ b/tests/unit_tests/plugin/test_plugin_id_parsing.py @@ -5,10 +5,6 @@ import pytest from langbot.pkg.plugin.connector import PluginRuntimeConnector -def test_parse_plugin_id_accepts_author_name(): - assert PluginRuntimeConnector._parse_plugin_id('langbot/rag-engine') == ('langbot', 'rag-engine') - - @pytest.mark.parametrize( 'plugin_id', [ diff --git a/tests/unit_tests/provider/test_litellmchat.py b/tests/unit_tests/provider/test_litellmchat.py index 311367d5f..b5dff4b34 100644 --- a/tests/unit_tests/provider/test_litellmchat.py +++ b/tests/unit_tests/provider/test_litellmchat.py @@ -1048,10 +1048,8 @@ class TestInvokeRerank: documents=['Paris is the capital.', 'London is a city.', 'France is in Europe.'], ) - assert len(results) == 3 - # Scores should be normalized - assert results[0]['index'] == 0 - assert results[0]['relevance_score'] >= 0 and results[0]['relevance_score'] <= 1 + assert [result['index'] for result in results] == [0, 1, 2] + assert [result['relevance_score'] for result in results] == pytest.approx([1.0, 0.0, 0.76923076923]) @pytest.mark.asyncio async def test_invoke_rerank_normalization(self): @@ -1297,8 +1295,8 @@ class TestConvertMessages: result = requester._convert_messages(messages) assert len(result) == 1 - # LiteLLM handles multiple text parts, we pass them through - assert isinstance(result[0]['content'], list) + assert result[0]['role'] == 'user' + assert result[0]['content'] == [{'type': 'text', 'text': 'Hello'}, {'type': 'text', 'text': 'World'}] class TestScanModels: diff --git a/tests/unit_tests/provider/test_requester_base.py b/tests/unit_tests/provider/test_requester_base.py index 017c9e705..e909a1a24 100644 --- a/tests/unit_tests/provider/test_requester_base.py +++ b/tests/unit_tests/provider/test_requester_base.py @@ -56,11 +56,8 @@ def test_requester_base_class_is_abstract(): mock_app = SimpleNamespace() mock_app.logger = Mock() - # ProviderAPIRequester has abstract methods, but ABCMeta allows instantiation - # if you don't call the abstract methods. Test that it has abstract methods. - assert hasattr(requester.ProviderAPIRequester, 'invoke_llm') - # Check that invoke_llm is abstract - assert hasattr(requester.ProviderAPIRequester.invoke_llm, '__isabstractmethod__') + with pytest.raises(TypeError, match='abstract'): + requester.ProviderAPIRequester(mock_app, {}) def test_requester_default_config_merged(): @@ -250,16 +247,6 @@ def test_runtime_provider_initialization(runtime_provider, fake_persistence_data assert isinstance(provider.requester, requester.ProviderAPIRequester) -def test_runtime_provider_has_invoke_methods(runtime_provider): - """Test RuntimeProvider has invoke methods that delegate to requester.""" - provider = runtime_provider - - assert hasattr(provider, 'invoke_llm') - assert hasattr(provider, 'invoke_llm_stream') - assert hasattr(provider, 'invoke_embedding') - assert hasattr(provider, 'invoke_rerank') - - @pytest.mark.asyncio async def test_runtime_provider_invoke_llm_delegates(runtime_provider, runtime_llm_model): """Test RuntimeProvider.invoke_llm delegates to requester.""" @@ -404,6 +391,7 @@ async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider assert len(chunks) == 1 assert chunks[0].role == 'assistant' + assert [(part.type, part.text) for part in chunks[0].content] == [('text', 'Fake stream chunk')] @pytest.mark.asyncio @@ -513,13 +501,11 @@ def test_runtime_llm_model_initialization(runtime_llm_model, fake_persistence_da assert model.reasoning_config_override is None -def test_runtime_llm_model_provider_ref(runtime_llm_model): +def test_runtime_llm_model_provider_ref(runtime_llm_model, runtime_provider): """Test RuntimeLLMModel has correct provider reference.""" model = runtime_llm_model - assert model.provider.provider_entity is not None - assert model.provider.token_mgr is not None - assert model.provider.requester is not None + assert model.provider is runtime_provider # ============================================================================ diff --git a/tests/unit_tests/provider/test_session_manager.py b/tests/unit_tests/provider/test_session_manager.py index 456a3a9c9..39d5c835a 100644 --- a/tests/unit_tests/provider/test_session_manager.py +++ b/tests/unit_tests/provider/test_session_manager.py @@ -365,7 +365,10 @@ class TestSessionManagerGetConversation: ) assert conversation.prompt.name == 'default' - assert len(conversation.prompt.messages) == 2 + assert [(message.role, message.content) for message in conversation.prompt.messages] == [ + ('system', 'System message'), + ('user', 'User message'), + ] class TestSessionManagerWorkspaceIsolation: diff --git a/tests/unit_tests/quality/test_required_contract_report.py b/tests/unit_tests/quality/test_required_contract_report.py index 0c7c5cab5..79add8c29 100644 --- a/tests/unit_tests/quality/test_required_contract_report.py +++ b/tests/unit_tests/quality/test_required_contract_report.py @@ -1,72 +1,128 @@ -"""The optional-dependency CI guard must reject a false-green JUnit report.""" +"""Exercise the required-suite policy through real pytest collection and execution.""" -from __future__ import annotations - -import importlib.util from pathlib import Path +import os import subprocess import sys import pytest -SCRIPT = Path(__file__).resolve().parents[3] / 'scripts' / 'assert-required-tests.py' -SPEC = importlib.util.spec_from_file_location('required_contract_report', SCRIPT) -assert SPEC is not None and SPEC.loader is not None -REPORT = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(REPORT) +ROOT = Path(__file__).resolve().parents[3] +PASSING = """ +import pytest + +@pytest.mark.security +@pytest.mark.contract('debug_exec_absent') +@pytest.mark.parametrize('value', [1, 2, 3]) +def test_renamed_behavior(value): + assert value > 0 + +@pytest.mark.security +@pytest.mark.contract('debug_route_absent') +def test_another_name(): + pass + +def test_unrelated(): + assert False +""" -def write_report(tmp_path, cases): - path = tmp_path / 'report.xml' - path.write_text(f'{cases}') - return path - - -def test_accepts_exact_passing_cases(tmp_path): - path = write_report( - tmp_path, '' +def run_suite(tmp_path, source, *args): + (tmp_path / 'moved').mkdir(exist_ok=True) + (tmp_path / 'moved/test_any_name.py').write_text(source) + (tmp_path / 'pytest.ini').write_text('[pytest]\nmarkers =\n security: suite\n contract(name): behavior\n') + return subprocess.run( + [ + sys.executable, + '-m', + 'pytest', + '-p', + 'tests.utils.required_suites', + '--required-suite', + 'security', + '-q', + *args, + ], + cwd=tmp_path, + env={**os.environ, 'PYTHONPATH': str(ROOT)}, + capture_output=True, + text=True, + timeout=30, ) - assert REPORT.verify_report(path, 'contracts', 2) == 2 -@pytest.mark.parametrize('outcome', ['skipped', 'failure', 'error']) -def test_rejects_every_nonpassing_outcome(tmp_path, outcome): - path = write_report(tmp_path, f'<{outcome}/>') - with pytest.raises(ValueError, match=outcome): - REPORT.verify_report(path, 'contracts', 1) +def test_renames_moves_and_parameter_growth_need_no_ci_edit(tmp_path): + result = run_suite(tmp_path, PASSING) + assert result.returncode == 0, result.stdout + result.stderr + assert '4 passed, 1 deselected' in result.stdout + result = run_suite(tmp_path, PASSING.replace('[1, 2, 3]', '[1, 2, 3, 4]')) + assert result.returncode == 0, result.stdout + result.stderr + assert '5 passed, 1 deselected' in result.stdout -@pytest.mark.parametrize('count', [0, 2]) -def test_rejects_changed_case_count(tmp_path, count): - path = write_report(tmp_path, '' * count) - with pytest.raises(ValueError, match='Expected 1 tests'): - REPORT.verify_report(path, 'contracts', 1) +@pytest.mark.parametrize( + 'statement', + [ + "pytest.skip('dependency unavailable')", + "pytest.xfail('known bug')", + 'assert False', + "raise RuntimeError('broken')", + ], +) +def test_required_cases_must_actually_pass(tmp_path, statement): + result = run_suite(tmp_path, PASSING.replace(' pass', f' {statement}')) + assert result.returncode != 0 + assert 'all selected cases must pass' in result.stdout -def test_rejects_wrong_module(tmp_path): - path = write_report(tmp_path, '') - with pytest.raises(ValueError, match='Unexpected test module'): - REPORT.verify_report(path, 'contracts', 1) +def test_unexpected_xpass_is_not_a_required_pass(tmp_path): + source = PASSING.replace('def test_another_name():', '@pytest.mark.xfail(strict=False)\ndef test_another_name():') + result = run_suite(tmp_path, source) + assert result.returncode != 0 + assert 'all selected cases must pass' in result.stdout -def test_rejects_missing_report(tmp_path): - with pytest.raises(FileNotFoundError): - REPORT.verify_report(tmp_path / 'missing.xml', 'contracts', 1) +def test_missing_behavior_cannot_be_replaced_by_more_unrelated_cases(tmp_path): + source = PASSING.replace("@pytest.mark.contract('debug_route_absent')", '') + result = run_suite(tmp_path, source) + assert result.returncode != 0 + assert "missing contracts: ['debug_route_absent']" in result.stderr -def test_command_line_success_and_failure(tmp_path): - path = write_report(tmp_path, '') - command = [sys.executable, str(SCRIPT), str(path), '--module', 'contracts', '--expected-tests'] - good = subprocess.run([*command, '1'], capture_output=True, text=True) - assert good.returncode == 0 - assert '1 required tests passed, 0 skipped' in good.stdout - bad = subprocess.run([*command, '0'], capture_output=True, text=True) - assert bad.returncode != 0 - assert 'must be positive' in bad.stderr +def test_empty_suite_fails(tmp_path): + result = run_suite(tmp_path, 'def test_unrelated(): pass') + assert result.returncode != 0 + assert 'missing contracts' in result.stderr -def test_rejects_wrong_function(tmp_path): - path = write_report(tmp_path, '') - with pytest.raises(ValueError, match='Unexpected test name'): - REPORT.verify_report(path, 'contracts', 1, 'test_required') +@pytest.mark.parametrize('args', [('-k', 'renamed'), ('-m', 'security'), ('--deselect', 'moved/test_any_name.py')]) +def test_required_suite_rejects_partial_filters(tmp_path, args): + result = run_suite(tmp_path, PASSING, *args) + assert result.returncode != 0 + assert 'cannot be combined' in result.stderr + + +@pytest.mark.parametrize('phase', ['setup', 'teardown']) +def test_fixture_skips_also_fail(tmp_path, phase): + fixture = '\n@pytest.fixture(autouse=True)\ndef dependency():\n' + if phase == 'teardown': + fixture += ' yield\n' + fixture += " pytest.skip('dependency unavailable')\n" + result = run_suite(tmp_path, PASSING + fixture) + assert result.returncode != 0 + assert 'all selected cases must pass' in result.stdout + + +def test_optional_local_execution_still_allows_skips(tmp_path): + (tmp_path / 'test_optional.py').write_text("import pytest\ndef test_optional(): pytest.skip('no database')") + result = subprocess.run( + [sys.executable, '-m', 'pytest', '-p', 'tests.utils.required_suites', '-q'], + cwd=tmp_path, + env={**os.environ, 'PYTHONPATH': str(ROOT)}, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert '1 skipped' in result.stdout diff --git a/tests/unit_tests/rag/test_file_storage.py b/tests/unit_tests/rag/test_file_storage.py index 8886e221a..f658218fe 100644 --- a/tests/unit_tests/rag/test_file_storage.py +++ b/tests/unit_tests/rag/test_file_storage.py @@ -300,7 +300,9 @@ class TestStoreFileTask: task_context.set_current_action.assert_called_once_with('Processing file') kb.ap.storage_mgr.storage_provider.size.assert_awaited_once_with(object_key) kb._ingest_document.assert_awaited_once() - assert kb.ap.persistence_mgr.execute_async.await_count == 2 + statements = [call.args[0].compile().params for call in kb.ap.persistence_mgr.execute_async.await_args_list] + assert [params['status'] for params in statements] == ['processing', 'completed'] + assert statements[-1]['engine_document_id'] == 'file-uuid' kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(object_key) @pytest.mark.asyncio @@ -316,7 +318,9 @@ class TestStoreFileTask: with pytest.raises(Exception, match='parser failed'): await kb._store_file_task(CONTEXT, file_obj, task_context) - assert kb.ap.persistence_mgr.execute_async.await_count == 2 + statements = [call.args[0].compile().params for call in kb.ap.persistence_mgr.execute_async.await_args_list] + assert [params['status'] for params in statements] == ['processing', 'failed'] + assert statements[-1]['engine_document_id'] == 'file-uuid' kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(object_key) diff --git a/tests/unit_tests/rag/test_kbmgr.py b/tests/unit_tests/rag/test_kbmgr.py index 9ea0c54c9..01f9a9225 100644 --- a/tests/unit_tests/rag/test_kbmgr.py +++ b/tests/unit_tests/rag/test_kbmgr.py @@ -662,7 +662,9 @@ class TestRuntimeKnowledgeBaseRetrieve: assert len(results) == 1 assert results[0].id == 'doc1' - assert hasattr(results[0], 'content') + assert results[0].content[0].text == 'test content' + assert results[0].metadata == {'source': 'file.pdf'} + assert results[0].distance == 0.15 class TestRuntimeKnowledgeBaseDispose: diff --git a/tests/unit_tests/storage/test_s3storage.py b/tests/unit_tests/storage/test_s3storage.py index 7e90cf43d..98ebdb20d 100644 --- a/tests/unit_tests/storage/test_s3storage.py +++ b/tests/unit_tests/storage/test_s3storage.py @@ -12,8 +12,9 @@ Uses moto library to mock AWS S3 service. from __future__ import annotations import pytest -from unittest.mock import Mock +from unittest.mock import Mock, patch from importlib import import_module +from botocore.exceptions import ClientError def get_s3storage_module(): @@ -57,14 +58,6 @@ def s3_mock(): class TestS3StorageProviderInit: """Tests for S3StorageProvider initialization.""" - def test_init_stores_app_reference(self): - """Test that __init__ stores the Application reference.""" - s3storage = get_s3storage_module() - - mock_app = Mock() - provider = s3storage.S3StorageProvider(mock_app) - assert provider.ap is mock_app - def test_init_s3_client_none(self): """Test that s3_client starts as None.""" s3storage = get_s3storage_module() @@ -102,6 +95,7 @@ class TestS3StorageProviderWithMoto: assert provider.s3_client is not None assert provider.bucket_name == 'test-langbot-storage' mock_app_with_s3_config.logger.info.assert_called() + assert [bucket['Name'] for bucket in s3_mock.list_buckets()['Buckets']] == ['test-langbot-storage'] @pytest.mark.asyncio async def test_initialize_uses_existing_bucket(self, mock_app_with_s3_config, s3_mock): @@ -112,11 +106,16 @@ class TestS3StorageProviderWithMoto: s3_mock.create_bucket(Bucket='test-langbot-storage') provider = s3storage.S3StorageProvider(mock_app_with_s3_config) - await provider.initialize() + with ( + patch.object(s3storage.boto3.session.Session, 'client', return_value=s3_mock), + patch.object(s3_mock, 'head_bucket', wraps=s3_mock.head_bucket) as head_bucket, + patch.object(s3_mock, 'create_bucket', wraps=s3_mock.create_bucket) as create_bucket, + ): + await provider.initialize() - assert provider.s3_client is not None - # Bucket creation log should not be called since bucket exists - # Note: moto may still call head_bucket successfully + assert provider.s3_client is s3_mock + head_bucket.assert_called_once_with(Bucket='test-langbot-storage') + create_bucket.assert_not_called() @pytest.mark.asyncio async def test_save_and_load_bytes(self, mock_app_with_s3_config, s3_mock): @@ -232,7 +231,7 @@ class TestS3StorageProviderWithMoto: @pytest.mark.asyncio async def test_delete_dir_recursive_handles_trailing_slash(self, mock_app_with_s3_config, s3_mock): - """Test that delete_dir_recursive handles path without trailing slash.""" + """Test that delete_dir_recursive handles a path with a trailing slash.""" s3storage = get_s3storage_module() provider = s3storage.S3StorageProvider(mock_app_with_s3_config) @@ -241,8 +240,7 @@ class TestS3StorageProviderWithMoto: # Save object await provider.save('mydir/file.txt', b'data') - # Delete without trailing slash - await provider.delete_dir_recursive('mydir') + await provider.delete_dir_recursive('mydir/') # Verify deleted assert await provider.exists('mydir/file.txt') is False @@ -325,8 +323,9 @@ class TestS3StorageProviderErrorHandling: provider = s3storage.S3StorageProvider(mock_app) await provider.initialize() - with pytest.raises(Exception): + with pytest.raises(ClientError) as exc_info: await provider.load('nonexistent.txt') + assert exc_info.value.response['Error']['Code'] == 'NoSuchKey' @pytest.mark.asyncio async def test_size_nonexistent_raises_error(self, s3_mock): @@ -350,5 +349,6 @@ class TestS3StorageProviderErrorHandling: provider = s3storage.S3StorageProvider(mock_app) await provider.initialize() - with pytest.raises(Exception): + with pytest.raises(ClientError) as exc_info: await provider.size('nonexistent.txt') + assert exc_info.value.response['Error']['Code'] == '404' diff --git a/tests/unit_tests/storage/test_storage_manager.py b/tests/unit_tests/storage/test_storage_manager.py index e17c317bd..ab7c1eb4a 100644 --- a/tests/unit_tests/storage/test_storage_manager.py +++ b/tests/unit_tests/storage/test_storage_manager.py @@ -9,7 +9,6 @@ from unittest.mock import Mock, AsyncMock, patch from langbot.pkg.storage.mgr import StorageMgr from langbot.pkg.storage.providers.localstorage import LocalStorageProvider -from langbot.pkg.storage.providers.s3storage import S3StorageProvider class TestStorageMgr: @@ -21,77 +20,6 @@ class TestStorageMgr: storage_mgr = StorageMgr(mock_app) assert storage_mgr.ap == mock_app - @pytest.mark.asyncio - async def test_initialize_default_local(self): - """Should use local storage by default.""" - mock_app = Mock() - mock_app.instance_config = Mock() - mock_app.instance_config.data = {} - mock_app.logger = Mock() - - storage_mgr = StorageMgr(mock_app) - - with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock): - await storage_mgr.initialize() - assert isinstance(storage_mgr.storage_provider, LocalStorageProvider) - mock_app.logger.info.assert_called() - - @pytest.mark.asyncio - async def test_initialize_with_explicit_local(self): - """Should use local storage when explicitly configured.""" - mock_app = Mock() - mock_app.instance_config = Mock() - mock_app.instance_config.data = {'storage': {'use': 'local'}} - mock_app.logger = Mock() - - storage_mgr = StorageMgr(mock_app) - - with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock): - await storage_mgr.initialize() - assert isinstance(storage_mgr.storage_provider, LocalStorageProvider) - - @pytest.mark.asyncio - async def test_initialize_with_s3(self): - """Should use S3 storage when configured.""" - mock_app = Mock() - mock_app.instance_config = Mock() - mock_app.instance_config.data = {'storage': {'use': 's3', 's3': {'endpoint_url': 'https://s3.amazonaws.com'}}} - mock_app.logger = Mock() - - storage_mgr = StorageMgr(mock_app) - - with patch.object(S3StorageProvider, 'initialize', new_callable=AsyncMock): - await storage_mgr.initialize() - assert isinstance(storage_mgr.storage_provider, S3StorageProvider) - - @pytest.mark.asyncio - async def test_initialize_invalid_type_defaults_to_local(self): - """Should default to local storage for invalid storage type.""" - mock_app = Mock() - mock_app.instance_config = Mock() - mock_app.instance_config.data = {'storage': {'use': 'invalid_type'}} - mock_app.logger = Mock() - - storage_mgr = StorageMgr(mock_app) - - with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock): - await storage_mgr.initialize() - assert isinstance(storage_mgr.storage_provider, LocalStorageProvider) - - @pytest.mark.asyncio - async def test_initialize_calls_provider_initialize(self): - """Should call the provider's initialize method.""" - mock_app = Mock() - mock_app.instance_config = Mock() - mock_app.instance_config.data = {} - mock_app.logger = Mock() - - storage_mgr = StorageMgr(mock_app) - - with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock) as mock_init: - await storage_mgr.initialize() - mock_init.assert_called_once() - @pytest.mark.asyncio async def test_shutdown_delegates_to_active_provider(self): storage_mgr = StorageMgr(Mock()) diff --git a/tests/unit_tests/survey/test_survey_manager.py b/tests/unit_tests/survey/test_survey_manager.py index da67e8219..aac76e3ed 100644 --- a/tests/unit_tests/survey/test_survey_manager.py +++ b/tests/unit_tests/survey/test_survey_manager.py @@ -234,6 +234,12 @@ class TestTriggerEvent: await manager.trigger_event('new_event') assert 'new_event' in manager._triggered_events + statements = mock_app.persistence_mgr.execute_async.await_args_list + assert len(statements) == 2 + assert statements[-1].args[0].compile().params == { + 'key': 'survey_triggered_events', + 'value': '["new_event"]', + } class TestRecordBotResponseSuccess: @@ -255,8 +261,9 @@ class TestRecordBotResponseSuccess: await manager.record_bot_response_success() assert manager._bot_response_count == 1 - # select + insert for the count key - assert mock_app.persistence_mgr.execute_async.call_count >= 2 + statements = mock_app.persistence_mgr.execute_async.await_args_list + assert len(statements) == 2 + assert statements[-1].args[0].compile().params == {'key': 'survey_bot_response_count', 'value': '1'} @pytest.mark.asyncio async def test_fires_milestone_event_at_threshold(self): diff --git a/tests/unit_tests/telemetry/test_telemetry.py b/tests/unit_tests/telemetry/test_telemetry.py index 87699a18d..230710b31 100644 --- a/tests/unit_tests/telemetry/test_telemetry.py +++ b/tests/unit_tests/telemetry/test_telemetry.py @@ -83,7 +83,9 @@ class TestTelemetrySendEarlyReturn: manager.telemetry_config = {} # Should return without making HTTP calls - await manager.send({'query_id': 'test'}) + with patch.object(httpx, 'AsyncClient') as client: + await manager.send({'query_id': 'test'}) + client.assert_not_called() # No HTTP client should be created, no logs should be written mock_app.logger.debug.assert_not_called() @@ -99,7 +101,9 @@ class TestTelemetrySendEarlyReturn: manager = telemetry.TelemetryManager(mock_app) manager.telemetry_config = {'disable_telemetry': True, 'url': 'https://example.com'} - await manager.send({'query_id': 'test'}) + with patch.object(httpx, 'AsyncClient') as client: + await manager.send({'query_id': 'test'}) + client.assert_not_called() mock_app.logger.debug.assert_not_called() @@ -113,7 +117,9 @@ class TestTelemetrySendEarlyReturn: manager = telemetry.TelemetryManager(mock_app) manager.telemetry_config = {'url': ''} - await manager.send({'query_id': 'test'}) + with patch.object(httpx, 'AsyncClient') as client: + await manager.send({'query_id': 'test'}) + client.assert_not_called() mock_app.logger.debug.assert_not_called() @@ -627,4 +633,3 @@ class TestAuthenticatedWorkspaceReporter: assert mock_client.post.call_args.kwargs['headers'] == { 'Authorization': 'Bearer refreshed-workspace-owner-token' } - diff --git a/tests/unit_tests/test_matrix_relogin.py b/tests/unit_tests/test_matrix_relogin.py index 03ec63ff3..f4aa8778c 100644 --- a/tests/unit_tests/test_matrix_relogin.py +++ b/tests/unit_tests/test_matrix_relogin.py @@ -1,61 +1,89 @@ +"""Regression tests for the production Matrix ``!relogin`` command.""" + from __future__ import annotations -"""Regression test for the Matrix ``!relogin`` command (``_handle_relogin_command``). +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, call -The old code placed ``logout_cmd = ...`` on a line after an unconditional -``continue`` inside the ``if not bridge.login_command or not bridge.dm_room_id`` -branch. Because the ``continue`` always fired, ``logout_cmd`` was never assigned -on the configured path, so any bridge with both ``login_command`` and -``dm_room_id`` raised ``UnboundLocalError``. The fix moves the assignment above -the ``if`` so it runs for configured bridges. +import pytest -These tests replicate the loop's control flow with a minimal fake bridge so they -run without the Matrix SDK / langbot_plugin dependency. -""" +from langbot.pkg.platform.sources import matrix -class FakeBridge: - def __init__(self, user_id: str, login_command: str, logout_command: str = '', dm_room_id: str | None = None): - self.user_id = user_id - self.login_command = login_command - self.logout_command = logout_command - self.dm_room_id = dm_room_id +@pytest.fixture +def bridge(): + bridge = matrix.BridgeState('@u:example.org', 'login', '', '', '') + bridge.dm_room_id = '!bridge:example.org' + bridge.logged_in = True + return bridge -def _relogin_commands(bridges: list[FakeBridge]) -> list[str]: - """Return the logout commands the fixed loop would send for each bridge.""" - commands: list[str] = [] - for bridge in bridges: - if not bridge.login_command or not bridge.dm_room_id: - continue - # Use configured logout command, fallback to deriving from login command. - logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') - commands.append(logout_cmd) - return commands +@pytest.fixture +def adapter(monkeypatch): + adapter = matrix.MatrixAdapter.model_construct( + client=SimpleNamespace(room_send=AsyncMock()), + logger=None, + ) + monkeypatch.setattr(matrix.asyncio, 'sleep', AsyncMock()) + monkeypatch.setattr(matrix.MatrixAdapter, '_restart_bridge_login', Mock()) + return adapter -def test_configured_bridge_produces_logout_command_without_error() -> None: - bridges = [FakeBridge('@u:example.org', 'login', dm_room_id='!room:example.org')] - # Old code raised UnboundLocalError here; the fix must return the derived - # logout command (no configured logout_command -> derive from login). - assert _relogin_commands(bridges) == ['logout'] +async def test_configured_bridge_produces_logout_command_without_error(adapter, bridge) -> None: + adapter._bridges = [bridge] + + await adapter._handle_relogin_command('!admin:example.org') + + assert adapter.client.room_send.await_count == 2 + assert adapter.client.room_send.await_args_list[0] == call( + room_id='!bridge:example.org', + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': 'logout'}, + ) + adapter._restart_bridge_login.assert_called_once_with(bridge) + assert bridge.logged_in is False -def test_configured_logout_command_is_used_verbatim() -> None: - bridges = [FakeBridge('@u:example.org', 'login', logout_command='leave', dm_room_id='!room:example.org')] - assert _relogin_commands(bridges) == ['leave'] +async def test_configured_logout_command_is_used_verbatim(adapter, bridge) -> None: + bridge.logout_command = 'leave' + adapter._bridges = [bridge] + + await adapter._handle_relogin_command('!admin:example.org') + + assert adapter.client.room_send.await_count == 2 + assert adapter.client.room_send.await_args_list[0] == call( + room_id='!bridge:example.org', + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': 'leave'}, + ) + adapter._restart_bridge_login.assert_called_once_with(bridge) + assert bridge.logged_in is False -def test_skipped_bridge_is_ignored() -> None: - # Missing dm_room_id -> skipped, no command emitted. - bridges = [FakeBridge('@u:example.org', 'login', dm_room_id=None)] - assert _relogin_commands(bridges) == [] +async def test_skipped_bridge_is_ignored(adapter, bridge) -> None: + bridge.dm_room_id = None + adapter._bridges = [bridge] + + await adapter._handle_relogin_command('!admin:example.org') + + adapter.client.room_send.assert_awaited_once() + assert adapter.client.room_send.await_args.kwargs['room_id'] == '!admin:example.org' + adapter._restart_bridge_login.assert_not_called() + assert bridge.logged_in is True -def test_relogin_never_raises_for_mixed_configurations() -> None: - bridges = [ - FakeBridge('@skip:example.org', '', dm_room_id='!room:example.org'), # no login_command - FakeBridge('@ok:example.org', 'login', dm_room_id='!room:example.org'), - FakeBridge('@skip2:example.org', 'login', dm_room_id=None), # no dm_room_id +async def test_relogin_never_raises_for_mixed_configurations(adapter, bridge) -> None: + missing_login = matrix.BridgeState('@skip:example.org', '', '', '', '') + missing_login.dm_room_id = '!skip:example.org' + missing_room = matrix.BridgeState('@skip2:example.org', 'login', '', '', '') + adapter._bridges = [missing_login, bridge, missing_room] + + await adapter._handle_relogin_command('!admin:example.org') + + assert [sent.kwargs['room_id'] for sent in adapter.client.room_send.await_args_list] == [ + '!bridge:example.org', + '!admin:example.org', ] - assert _relogin_commands(bridges) == ['logout'] + assert adapter.client.room_send.await_args_list[0].kwargs['content']['body'] == 'logout' + adapter._restart_bridge_login.assert_called_once_with(bridge) + assert bridge.logged_in is False diff --git a/tests/unit_tests/utils/test_image.py b/tests/unit_tests/utils/test_image.py index 5c4f9eb50..ceb537aff 100644 --- a/tests/unit_tests/utils/test_image.py +++ b/tests/unit_tests/utils/test_image.py @@ -70,8 +70,8 @@ class TestGetQQImageDownloadableUrl: url = 'http://example.com/image.jpg#fragment' result_url, query = get_qq_image_downloadable_url(url) - # Fragment is not included in query string parsing - assert 'http://example.com/image.jpg' in result_url + assert result_url == 'http://example.com/image.jpg' + assert query == {} def test_https_url(self): """Parse HTTPS URL and preserve its scheme.""" diff --git a/tests/unit_tests/utils/test_importutil.py b/tests/unit_tests/utils/test_importutil.py index cfd7fc23e..5eeb5ee49 100644 --- a/tests/unit_tests/utils/test_importutil.py +++ b/tests/unit_tests/utils/test_importutil.py @@ -15,13 +15,14 @@ Uses mocking for import operations to avoid actual module imports. import pytest import importlib -from unittest.mock import patch, MagicMock +import os +from unittest.mock import patch, MagicMock, call class TestImportDir: """Test import_dir function.""" - def test_calls_importlib_for_each_python_file(self, tmp_path): + def test_calls_importlib_for_each_python_file(self, tmp_path, monkeypatch): """Should call importlib.import_module for each .py file.""" module_dir = tmp_path / 'test_modules' module_dir.mkdir() @@ -33,12 +34,17 @@ class TestImportDir: from langbot.pkg.utils import importutil + monkeypatch.setattr(importutil, '__file__', str(tmp_path / 'pkg' / 'utils' / 'importutil.py')) with patch.object(importlib, 'import_module') as mock_import: importutil.import_dir(str(module_dir), path_prefix='test_prefix.') # Should call import_module for each .py file (excluding __init__.py) assert mock_import.call_count == 2 + mock_import.assert_has_calls( + [call('test_prefix.test_modules.module_a'), call('test_prefix.test_modules.module_b')], + any_order=True, + ) - def test_skips_init_py(self, tmp_path): + def test_skips_init_py(self, tmp_path, monkeypatch): """Should skip __init__.py when importing.""" module_dir = tmp_path / 'test_modules' module_dir.mkdir() @@ -48,15 +54,12 @@ class TestImportDir: from langbot.pkg.utils import importutil + monkeypatch.setattr(importutil, '__file__', str(tmp_path / 'pkg' / 'utils' / 'importutil.py')) with patch.object(importlib, 'import_module') as mock_import: importutil.import_dir(str(module_dir), path_prefix='test_prefix.') - # __init__.py should be skipped - mock_import.assert_called_once() - # The call should not include __init__ - call_args = mock_import.call_args[0][0] - assert '__init__' not in call_args + mock_import.assert_called_once_with('test_prefix.test_modules.regular') - def test_ignores_non_py_files(self, tmp_path): + def test_ignores_non_py_files(self, tmp_path, monkeypatch): """Should ignore non-.py files.""" module_dir = tmp_path / 'test_modules' module_dir.mkdir() @@ -67,10 +70,10 @@ class TestImportDir: from langbot.pkg.utils import importutil + monkeypatch.setattr(importutil, '__file__', str(tmp_path / 'pkg' / 'utils' / 'importutil.py')) with patch.object(importlib, 'import_module') as mock_import: importutil.import_dir(str(module_dir), path_prefix='test_prefix.') - # Only .py files should be imported - assert mock_import.call_count == 1 + mock_import.assert_called_once_with('test_prefix.test_modules.module') class TestImportModulesInPkg: @@ -107,7 +110,7 @@ class TestImportModulesInPkgs: with patch.object(importutil, 'import_modules_in_pkg') as mock_import: importutil.import_modules_in_pkgs([mock_pkg1, mock_pkg2]) - assert mock_import.call_count == 2 + assert mock_import.call_args_list == [call(mock_pkg1), call(mock_pkg2)] class TestImportDotStyleDir: @@ -124,10 +127,7 @@ class TestImportDotStyleDir: with patch.object(importutil, 'import_dir') as mock_import_dir: importutil.import_dot_style_dir('my.pkg.test') - # The path should be converted using os.path.join - call_path = mock_import_dir.call_args[0][0] - # Should contain the path components joined - assert 'my' in call_path + mock_import_dir.assert_called_once_with(os.path.join('my', 'pkg', 'test')) class TestReadResourceFile: diff --git a/tests/unit_tests/utils/test_paths.py b/tests/unit_tests/utils/test_paths.py index 0043a3338..9ebab2342 100644 --- a/tests/unit_tests/utils/test_paths.py +++ b/tests/unit_tests/utils/test_paths.py @@ -9,7 +9,6 @@ Tests path utility functions: Uses tmp_path for file system isolation where applicable. """ -import os import pytest @@ -45,17 +44,16 @@ class TestCheckIfSourceInstall: class TestGetFrontendPath: """Test get_frontend_path function.""" - def test_returns_web_dist_by_default(self): - """Should return a path containing web/dist as default.""" + def test_returns_web_dist_by_default(self, monkeypatch): + """Should return web/dist when no frontend build exists.""" from langbot.pkg.utils import paths - paths._is_source_install = None + monkeypatch.setattr(paths, '_is_source_install', False) + monkeypatch.setattr(paths.os.path, 'exists', lambda _: False) + monkeypatch.setattr(paths.Path, 'exists', lambda _: False) result = paths.get_frontend_path() - # The result should contain web/dist or be an absolute path to it - assert 'web/dist' in result or result.endswith('dist') - - paths._is_source_install = None + assert result == 'web/dist' def test_finds_dist_directory_in_source_mode(self, tmp_path, monkeypatch): """Should find web/dist when running from source mode.""" @@ -130,7 +128,7 @@ class TestGetResourcePath: paths._is_source_install = None result = paths.get_resource_path('templates/config.yaml') - assert os.path.exists(result) + assert result == 'templates/config.yaml' paths._is_source_install = None diff --git a/tests/unit_tests/utils/test_python_workspace.py b/tests/unit_tests/utils/test_python_workspace.py new file mode 100644 index 000000000..6bccb2ef9 --- /dev/null +++ b/tests/unit_tests/utils/test_python_workspace.py @@ -0,0 +1,78 @@ +"""Exercise generated shell/Python syntax without installing dependencies.""" + +import hashlib +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile +import unittest + +from langbot.pkg.utils.python_workspace import wrap_python_command_with_env + + +class TestPythonWorkspaceCommand(unittest.TestCase): + def test_generated_command_contains_no_null_bytes(self): + command = wrap_python_command_with_env('python main.py') + self.assertNotIn('\0', command) + + @unittest.skipUnless(shutil.which('sh'), 'POSIX shell required') + def test_generated_command_has_valid_shell_syntax(self): + command = wrap_python_command_with_env('python main.py') + result = subprocess.run(['sh', '-n', '-c', command], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + + @unittest.skipUnless(shutil.which('sh'), 'POSIX shell required') + def test_cached_environment_executes_command_and_hashes_manifest(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + mount = root / 'project' + state = root / 'state' + bin_dir = root / 'bin' + venv_bin = state / '.venv' / 'bin' + meta_dir = state / '.langbot' + for path in (mount, bin_dir, venv_bin, meta_dir): + path.mkdir(parents=True) + + manifest = b'# local fixture\nexample==1.0\n' + (mount / 'requirements.txt').write_bytes(manifest) + # Run the real metadata interpreter, but fail closed if bootstrap is attempted. + system_python = bin_dir / 'python3' + system_python.write_text( + '#!/bin/sh\n' + '[ "$#" -eq 1 ] && [ "$1" = "-" ] || exit 99\n' + f'exec {shlex.quote(sys.executable)} "$@"\n', + encoding='utf-8', + ) + system_python.chmod(0o755) + venv_python = venv_bin / 'python' + venv_python.write_text('#!/bin/sh\nprintf "%s\\n" "$VIRTUAL_ENV"\n', encoding='utf-8') + venv_python.chmod(0o755) + metadata = json.dumps( + { + 'python_executable': sys.executable, + 'python_version': list(sys.version_info[:3]), + 'manifest_files': ['requirements.txt'], + 'manifest_sha256': hashlib.sha256(b'requirements.txt\0' + manifest + b'\0').hexdigest(), + }, + sort_keys=True, + ) + meta_file = meta_dir / 'python-env.json' + meta_file.write_text(metadata, encoding='utf-8') + + command = wrap_python_command_with_env('python', mount_path=str(mount), state_path=str(state)) + result = subprocess.run( + ['sh', '-c', command], + env={**os.environ, 'PATH': f'{bin_dir}{os.pathsep}{os.environ.get("PATH", "")}'}, + capture_output=True, + text=True, + timeout=10, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, f'{state / ".venv"}\n') + self.assertEqual(meta_file.read_text(encoding='utf-8'), metadata) + self.assertFalse((meta_dir / 'python-env.lock').exists()) diff --git a/tests/unit_tests/vector/test_filter_utils.py b/tests/unit_tests/vector/test_filter_utils.py index 2bbf4a1c9..7b8a5e774 100644 --- a/tests/unit_tests/vector/test_filter_utils.py +++ b/tests/unit_tests/vector/test_filter_utils.py @@ -198,6 +198,4 @@ class TestSupportedOpsConstant: def test_supported_ops_is_frozenset(self): """SUPPORTED_OPS is a frozenset for immutability.""" - from collections.abc import Set - - assert isinstance(SUPPORTED_OPS, Set) + assert isinstance(SUPPORTED_OPS, frozenset) diff --git a/tests/unit_tests/vector/test_vdb_filter_conversion.py b/tests/unit_tests/vector/test_vdb_filter_conversion.py index cc79f62a4..00d9b6ba4 100644 --- a/tests/unit_tests/vector/test_vdb_filter_conversion.py +++ b/tests/unit_tests/vector/test_vdb_filter_conversion.py @@ -10,6 +10,9 @@ from __future__ import annotations from importlib import import_module +import pytest +from sqlalchemy import create_engine, select, text + def get_qdrant_module(): """Lazy import qdrant module.""" @@ -261,6 +264,37 @@ class TestPgVectorFilterConversion: Tests use only these supported fields. """ + @pytest.fixture + def matching_file_ids(self): + """Execute generated predicates against fixed rows, including the range boundary.""" + pgvector_module = get_pgvector_module() + engine = create_engine('sqlite://') + try: + with engine.begin() as connection: + connection.execute(text('CREATE TABLE langbot_vectors (file_id TEXT, chunk_uuid TEXT, text TEXT)')) + connection.execute( + text('INSERT INTO langbot_vectors VALUES (:file_id, :chunk_uuid, :text)'), + [ + {'file_id': 'abc', 'chunk_uuid': 'uuid_099', 'text': 'keep'}, + {'file_id': 'deleted', 'chunk_uuid': 'uuid_100', 'text': ''}, + {'file_id': 'def', 'chunk_uuid': 'uuid_101', 'text': 'keep'}, + {'file_id': 'outside', 'chunk_uuid': 'uuid_102', 'text': 'keep'}, + ], + ) + + def matching(filter_dict): + entry = pgvector_module.PgVectorEntry + statement = ( + select(entry.file_id) + .where(*pgvector_module._build_pg_conditions(filter_dict)) + .order_by(entry.chunk_uuid) + ) + return connection.execute(statement).scalars().all() + + yield matching + finally: + engine.dispose() + def test_empty_filter_returns_empty_list(self): """Empty filter dict returns empty list.""" pgvector_module = get_pgvector_module() @@ -268,73 +302,40 @@ class TestPgVectorFilterConversion: result = pgvector_module._build_pg_conditions({}) assert result == [] - def test_eq_operator_creates_equality_condition(self): + def test_eq_operator_creates_equality_condition(self, matching_file_ids): """$eq operator creates SQLAlchemy == condition.""" - pgvector_module = get_pgvector_module() + assert matching_file_ids({'file_id': 'abc'}) == ['abc'] - result = pgvector_module._build_pg_conditions({'file_id': 'abc'}) - - assert len(result) == 1 - # Verify it's a SQLAlchemy BinaryExpression - from sqlalchemy.sql.expression import BinaryExpression - - assert isinstance(result[0], BinaryExpression) - - def test_ne_operator_creates_inequality_condition(self): + def test_ne_operator_creates_inequality_condition(self, matching_file_ids): """$ne operator creates SQLAlchemy != condition.""" - pgvector_module = get_pgvector_module() + assert matching_file_ids({'file_id': {'$ne': 'deleted'}}) == ['abc', 'def', 'outside'] - result = pgvector_module._build_pg_conditions({'file_id': {'$ne': 'deleted'}}) - - assert len(result) == 1 - # Operator should be ne (not equals) - assert '!=' in str(result[0]) or 'ne' in str(result[0].operator) - - def test_comparison_operators(self): + def test_comparison_operators(self, matching_file_ids): """$gt, $gte, $lt, $lte create comparison conditions.""" - pgvector_module = get_pgvector_module() - - # Test all comparison operators with supported field - for op, expected_op in [ - ('$gt', '>'), - ('$gte', '>='), - ('$lt', '<'), - ('$lte', '<='), + for op, expected_ids in [ + ('$gt', ['def', 'outside']), + ('$gte', ['deleted', 'def', 'outside']), + ('$lt', ['abc']), + ('$lte', ['abc', 'deleted']), ]: - result = pgvector_module._build_pg_conditions({'chunk_uuid': {op: 'uuid_100'}}) - assert len(result) == 1 - assert expected_op in str(result[0]) + assert matching_file_ids({'chunk_uuid': {op: 'uuid_100'}}) == expected_ids - def test_in_operator_creates_in_condition(self): + def test_in_operator_creates_in_condition(self, matching_file_ids): """$in operator creates SQLAlchemy in_ condition.""" - pgvector_module = get_pgvector_module() + assert matching_file_ids({'file_id': {'$in': ['abc', 'deleted']}}) == ['abc', 'deleted'] - result = pgvector_module._build_pg_conditions({'file_id': {'$in': ['a', 'b', 'c']}}) - - assert len(result) == 1 - assert 'IN' in str(result[0]).upper() - - def test_nin_operator_creates_notin_condition(self): + def test_nin_operator_creates_notin_condition(self, matching_file_ids): """$nin operator creates SQLAlchemy notin_ condition.""" - pgvector_module = get_pgvector_module() + assert matching_file_ids({'file_id': {'$nin': ['abc', 'deleted']}}) == ['def', 'outside'] - result = pgvector_module._build_pg_conditions({'file_id': {'$nin': ['a', 'b']}}) - - assert len(result) == 1 - assert 'NOT IN' in str(result[0]).upper() - - def test_multiple_conditions_list(self): + def test_multiple_conditions_list(self, matching_file_ids): """Multiple conditions return list of conditions.""" - pgvector_module = get_pgvector_module() - - result = pgvector_module._build_pg_conditions( + assert matching_file_ids( { - 'file_id': 'abc', - 'chunk_uuid': {'$ne': 'def'}, + 'file_id': {'$in': ['abc', 'deleted']}, + 'chunk_uuid': {'$ne': 'uuid_100'}, } - ) - - assert len(result) == 2 + ) == ['abc'] def test_unsupported_field_dropped(self): """Unsupported fields are dropped (not in _PG_SUPPORTED_FIELDS).""" @@ -343,27 +344,17 @@ class TestPgVectorFilterConversion: result = pgvector_module._build_pg_conditions({'unknown_field': 'value'}) assert result == [] - def test_uuid_alias_resolved(self): + def test_uuid_alias_resolved(self, matching_file_ids): """'uuid' alias is resolved to 'chunk_uuid'.""" - pgvector_module = get_pgvector_module() + assert matching_file_ids({'uuid': 'uuid_100'}) == ['deleted'] - result = pgvector_module._build_pg_conditions({'uuid': 'abc'}) - - assert len(result) == 1 - # Should reference chunk_uuid column - assert 'chunk_uuid' in str(result[0]) - - def test_supported_fields_only(self): + def test_supported_fields_only(self, matching_file_ids): """Only supported fields (text, file_id, chunk_uuid) are kept.""" - pgvector_module = get_pgvector_module() - - result = pgvector_module._build_pg_conditions( + assert matching_file_ids( { 'text': {'$ne': ''}, - 'file_id': 'abc', - 'chunk_uuid': {'$in': ['x', 'y']}, + 'file_id': {'$in': ['abc', 'deleted', 'outside']}, + 'chunk_uuid': {'$in': ['uuid_099', 'uuid_100', 'uuid_101']}, 'unsupported': 'value', } - ) - - assert len(result) == 3 # Only supported fields + ) == ['abc'] diff --git a/tests/unit_tests/workspace/test_invitation_delivery.py b/tests/unit_tests/workspace/test_invitation_delivery.py index 347524d07..ff070282c 100644 --- a/tests/unit_tests/workspace/test_invitation_delivery.py +++ b/tests/unit_tests/workspace/test_invitation_delivery.py @@ -1,7 +1,7 @@ from __future__ import annotations from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -143,16 +143,24 @@ async def test_oss_smtp_configuration_delivers_the_generic_invitation_email(): } ) ) - service._send_smtp = AsyncMock(return_value=True) link = 'https://self-hosted.example/invitations/accept#token=lbi_secret' - result = await service.deliver_invitation( - recipient_email='member@example.com', - workspace_name='Self-hosted Workspace', - invitation_link=link, - ) + with patch('langbot.pkg.workspace.invitation_delivery.smtplib.SMTP') as smtp: + result = await service.deliver_invitation( + recipient_email='member@example.com', + workspace_name='Self-hosted Workspace', + invitation_link=link, + ) assert result == InvitationDeliveryResult(status='sent', provider='smtp') - service._send_smtp.assert_awaited_once() - assert 'LangBot Cloud' not in service._plain_text('Self-hosted Workspace', link) - assert 'LangBot Cloud' not in service._html('Self-hosted Workspace', link) + send_message = smtp.return_value.__enter__.return_value.send_message + send_message.assert_called_once() + message = send_message.call_args.args[0] + assert message['From'] == 'LangBot ' + assert message['To'] == 'member@example.com' + assert message['Subject'] == 'You were invited to Self-hosted Workspace' + for subtype in ('plain', 'html'): + content = message.get_body(preferencelist=(subtype,)).get_content() + assert 'Self-hosted Workspace' in content + assert link in content + assert 'LangBot Cloud' not in content diff --git a/tests/utils/legacy_plugin_contract.py b/tests/utils/legacy_plugin_contract.py index 0056ce05a..5d4fb15d8 100644 --- a/tests/utils/legacy_plugin_contract.py +++ b/tests/utils/legacy_plugin_contract.py @@ -1,4 +1,4 @@ -"""Load identity consumers from integrity-checked official plugin source.""" +"""Load contract fixtures from integrity-checked official plugin source.""" import ast import hashlib @@ -14,7 +14,7 @@ from langbot_plugin.api.entities.builtin.runner.context import RunnerContext SOURCE_LOCK = Path(__file__).resolve().parents[1] / 'fixtures' / 'legacy_plugin_identity_sources.json' -def verified_plugin_root() -> Path: +def verified_plugin_root(*, files_key: str = 'files') -> Path: """Allow optional local execution, but never skip an explicitly provisioned source.""" configured = os.environ.get('LANGBOT_LEGACY_PLUGIN_ROOT') if configured is None: @@ -23,7 +23,7 @@ def verified_plugin_root() -> Path: pytest.fail('LANGBOT_LEGACY_PLUGIN_ROOT is set but empty', pytrace=False) root = Path(configured).resolve() lock = json.loads(SOURCE_LOCK.read_text()) - for relative, expected in lock['files'].items(): + for relative, expected in lock[files_key].items(): path = root / relative if not path.resolve().is_relative_to(root) or not path.is_file(): pytest.fail(f'Official plugin fixture is missing or outside its root: {relative}', pytrace=False) diff --git a/tests/utils/required_suites.py b/tests/utils/required_suites.py new file mode 100644 index 000000000..d752e10c6 --- /dev/null +++ b/tests/utils/required_suites.py @@ -0,0 +1,71 @@ +"""Required suites select explicit markers and enforce execution, not case counts.""" + +import pytest + + +# These are behavior obligations, independent of file names and parametrization. +REQUIRED_CONTRACTS = { + 'postgres': { + 'admission_isolation', + 'rag_identity', + 'rag_recovery', + 'migration_history', + 'migration_fresh_schema', + 'runner_timestamps', + 'monitoring_sessions', + }, + 'local_agent': {'agent_ledger', 'agent_tools', 'agent_rag', 'agent_compaction', 'agent_multi_turn', 'agent_box'}, + 'security': {'debug_exec_absent', 'debug_route_absent'}, + 'compiled_cli': {'cli_http'}, + 'plugin_identity': {'dify_identity', 'n8n_identity', 'coze_identity', 'tbox_identity'}, + 'seekdb': {'vector_round_trip', 'hybrid_relevance'}, +} + + +def pytest_addoption(parser): + parser.addoption('--required-suite', choices=tuple(REQUIRED_CONTRACTS), help='Run a mandatory dependency suite') + + +def pytest_configure(config): + suite = config.getoption('--required-suite') + if suite: + if config.option.markexpr or config.option.keyword or config.option.deselect: + raise pytest.UsageError('--required-suite cannot be combined with -m, -k or --deselect') + config.option.markexpr = suite + config.pluginmanager.register(RequiredSuite(suite), 'required-suite-policy') + + +class RequiredSuite: + def __init__(self, suite): + self.suite = suite + self.items = {} + self.passed = set() + self.unsuccessful = set() + + def pytest_collection_finish(self, session): + for item in session.items: + self.items[item.nodeid] = {mark.args[0] for mark in item.iter_markers('contract')} + covered = set().union(*self.items.values()) + missing = REQUIRED_CONTRACTS[self.suite] - covered + if missing: + raise pytest.UsageError(f'Required suite {self.suite} is missing contracts: {sorted(missing)}') + + def pytest_runtest_logreport(self, report): + if report.nodeid not in self.items: + return + if report.failed or report.skipped or hasattr(report, 'wasxfail'): + self.unsuccessful.add(report.nodeid) + if report.when == 'call' and report.passed: + self.passed.add(report.nodeid) + + def pytest_sessionfinish(self, session, exitstatus): + if session.config.option.collectonly: + return + incomplete = set(self.items) - self.passed + if exitstatus == 0 and (incomplete or self.unsuccessful or not self.items): + session.exitstatus = pytest.ExitCode.TESTS_FAILED + reporter = session.config.pluginmanager.get_plugin('terminalreporter') + if reporter and (incomplete or self.unsuccessful): + reporter.write_sep('=', f'Required suite {self.suite}: all selected cases must pass without skips/xfails') + for nodeid in sorted(incomplete | self.unsuccessful): + reporter.write_line(nodeid) diff --git a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx index a0b49612e..2ba418c56 100644 --- a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx +++ b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx @@ -187,7 +187,7 @@ export default function ProviderCard({ /> )}
-
+
{provider.name} diff --git a/web/tests/e2e/account-oauth.spec.ts b/web/tests/e2e/account-oauth.spec.ts new file mode 100644 index 000000000..927fb0fda --- /dev/null +++ b/web/tests/e2e/account-oauth.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +for (const invitation of [false, true]) { + test(`Cloud login starts standard OAuth, invitation=${invitation}`, async ({ + page, + }) => { + await installLangBotApiMocks(page); + await page.route('**/api/v1/system/info', (route) => + route.fulfill({ + json: { + code: 0, + data: { + edition: 'cloud', + deployment_mode: 'cloud', + cloud_service_url: 'https://space.langbot.app', + wizard_status: 'completed', + }, + }, + }), + ); + await page.route('**/api/v1/user/account-info', (route) => + route.fulfill({ + json: { + code: 0, + data: { + initialized: true, + password_login_enabled: false, + space_login_enabled: true, + authenticated_invitation_acceptance_enabled: true, + }, + }, + }), + ); + if (invitation) + await page.addInitScript(() => + sessionStorage.setItem( + 'langbot_pending_invitation_token', + 'invited-token', + ), + ); + let redirectUri: string | null = null; + await page.route('**/api/v1/user/space/authorize-url**', async (route) => { + redirectUri = new URL(route.request().url()).searchParams.get( + 'redirect_uri', + ); + await route.fulfill({ + json: { code: 0, data: { authorize_url: '/oauth-provider' } }, + }); + }); + await page.route('**/oauth-provider', (route) => + route.fulfill({ + contentType: 'text/html', + body: '

OAuth provider

', + }), + ); + await page.goto(invitation ? '/login?invitation=1' : '/login'); + await page + .getByRole('button', { name: 'Login with LangBot Account', exact: true }) + .click(); + await expect(page).toHaveURL(/\/oauth-provider$/); + expect(redirectUri).toBe(new URL('/auth/space/callback', page.url()).href); + expect( + await page.evaluate(() => + sessionStorage.getItem('langbot_pending_invitation_token'), + ), + ).toBe(invitation ? 'invited-token' : null); + }); +} + +for (const [code, title, message] of [ + [ + 'space_account_not_registered', + 'Account not registered', + 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.', + ], + [ + 'space_account_binding_required', + 'LangBot Account connection required', + 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.', + ], +]) { + test(`OAuth callback explains ${code}`, async ({ page }) => { + await installLangBotApiMocks(page); + await page.route('**/api/v1/user/space/callback', (route) => + route.fulfill({ + status: 403, + json: { code, msg: 'Generic upstream message' }, + }), + ); + await page.goto('/auth/space/callback?code=code&state=state'); + await expect(page.getByText(title, { exact: true })).toBeVisible(); + await expect(page.getByText(message, { exact: true })).toBeVisible(); + await expect( + page.getByText('Generic upstream message', { exact: true }), + ).toHaveCount(0); + await page + .getByRole('button', { name: 'Back to Login', exact: true }) + .click(); + await expect(page).toHaveURL(/\/login$/); + expect(await page.evaluate(() => localStorage.getItem('token'))).toBeNull(); + }); +} diff --git a/web/tests/e2e/bot-form-layout.spec.ts b/web/tests/e2e/bot-form-layout.spec.ts new file mode 100644 index 000000000..978aad807 --- /dev/null +++ b/web/tests/e2e/bot-form-layout.spec.ts @@ -0,0 +1,133 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +for (const width of [1440, 390]) { + test(`bot setup and detail columns stay separated at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 1000 }); + await installLangBotApiMocks(page, { authenticated: true }); + await page.goto('/home/bots?id=new'); + const layout = page.locator('#bot-form > fieldset'); + const basic = layout.locator('[data-guide="bot-basic"]'); + const adapter = page.getByTestId('adapter-gallery'); + await expect(basic).toBeVisible(); + await expect(adapter).toBeVisible(); + const basicBox = (await basic.boundingBox())!; + const adapterBox = (await adapter.boundingBox())!; + if (width >= 1024) + expect(adapterBox.x).toBeGreaterThan(basicBox.x + basicBox.width); + else expect(adapterBox.y).toBeGreaterThan(basicBox.y + basicBox.height); + await adapter.getByRole('button', { name: /Playwright Adapter/ }).click(); + await page.locator('input[name="name"]').fill('Layout bot'); + await page.getByRole('button', { name: /^Submit$/ }).click(); + await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); + const routing = page.locator('[data-guide="bot-routing"]'); + await expect(routing).toBeVisible(); + const cards = await layout.locator(':scope > [data-slot="card"]').all(); + expect(cards).toHaveLength(2); + const left = (await cards[0].boundingBox())!; + const right = (await cards[1].boundingBox())!; + if (width >= 1024) expect(right.x).toBeGreaterThan(left.x + left.width); + else expect(right.y).toBeGreaterThan(left.y + left.height); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + await page.goto('/home/knowledge?id=new'); + const form = page.locator('#kb-form'); + await expect(form).toBeVisible(); + const children = await form.locator(':scope > *').all(); + expect(children.length).toBeGreaterThan(1); + for (let i = 1; i < children.length; i++) { + const previous = await children[i - 1].boundingBox(); + const current = await children[i].boundingBox(); + if (previous && current) + expect(current.y - previous.y - previous.height).toBeGreaterThanOrEqual( + 20, + ); + } + }); +} + +test('the dedicated connection selector hides its manifest controller and persists its value', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/platform/adapters', (route) => + route.fulfill({ + json: { + code: 0, + data: { + adapters: [ + { + name: 'connection-adapter', + label: { en_US: 'Connection adapter' }, + description: { en_US: 'Both connection modes' }, + spec: { + categories: ['testing'], + config: [ + { + name: 'transport', + label: { en_US: 'Raw transport controller' }, + type: 'select', + required: true, + default: 'persistent', + options: [ + { name: 'persistent', label: { en_US: 'Persistent' } }, + { + name: 'webhook', + label: { en_US: 'Webhook transport' }, + }, + ], + }, + { + name: 'webhook_url', + label: { en_US: 'Webhook address' }, + type: 'webhook-url', + show_if: { + field: 'transport', + operator: 'eq', + value: 'webhook', + }, + }, + { + name: 'credential', + label: { en_US: 'Adapter credential' }, + type: 'string', + required: true, + default: '', + }, + ], + }, + }, + ], + }, + }, + }), + ); + await page.goto('/home/bots?id=new'); + await page + .getByTestId('adapter-gallery') + .getByRole('button', { name: /Connection adapter/ }) + .click(); + await page.getByRole('button', { name: /^Submit$/ }).click(); + await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); + await page.getByRole('radio', { name: /^Webhook/ }).click(); + await expect(page.getByText(/^Raw transport controller/)).toHaveCount(0); + await expect( + page.getByText('Webhook address', { exact: true }), + ).toBeVisible(); + await page.locator('input[name="credential"]').fill('persist-me'); + const save = page.waitForRequest( + (request) => + request.method() === 'PUT' && + request.url().endsWith('/api/v1/platform/bots/bot-1'), + ); + await page.getByRole('button', { name: /^Save and enable$/ }).click(); + expect((await save).postDataJSON().adapter_config).toEqual({ + transport: 'webhook', + credential: 'persist-me', + }); +}); diff --git a/web/tests/e2e/bot-session-pagination-contract.spec.ts b/web/tests/e2e/bot-session-pagination-contract.spec.ts new file mode 100644 index 000000000..61d6e9c04 --- /dev/null +++ b/web/tests/e2e/bot-session-pagination-contract.spec.ts @@ -0,0 +1,256 @@ +import { expect, test, type Page } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +const botId = 'bot-tool-timeline'; +const timestamp = (index: number) => + new Date(Date.UTC(2026, 2, 8, 10, index)).toISOString(); +const message = (index: number) => ({ + id: `page-message-${index}`, + timestamp: timestamp(index), + bot_id: botId, + bot_name: 'Boundary Bot', + pipeline_id: 'pipeline-tool-timeline', + pipeline_name: 'Boundary Pipeline', + message_content: `message row ${index}`, + session_id: 'person-boundary-0', + role: 'assistant', + status: 'success', + level: 'info', + platform: 'bot', + user_id: 'boundary-user', + user_name: 'Boundary User', +}); + +async function openMonitor(page: Page) { + await installLangBotApiMocks(page, { authenticated: true }); + const requests: { + path: string; + query: Record; + headers: Record; + }[] = []; + await page.route('**/api/v1/monitoring/**', async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + if (!/\/(sessions|messages|analysis)$/.test(path)) return route.fallback(); + requests.push({ + path, + query: Object.fromEntries(url.searchParams), + headers: route.request().headers(), + }); + const offset = Number(url.searchParams.get('offset') ?? 0); + let data: unknown; + if (path.endsWith('/sessions')) { + data = { + total: 21, + sessions: [ + { + session_id: `person-boundary-${offset}`, + bot_id: botId, + bot_name: 'Boundary Bot', + pipeline_id: 'pipeline-tool-timeline', + pipeline_name: 'Boundary Pipeline', + user_id: `boundary-${offset}`, + user_name: offset ? 'Page two User' : 'Boundary User', + message_count: 52, + start_time: timestamp(0), + last_activity: timestamp(51), + is_active: true, + }, + ], + }; + } else if (path.endsWith('/messages')) { + data = { + total: 52, + messages: Array.from({ length: 52 }, (_, index) => message(index)) + .slice(offset, offset + 50) + .reverse(), + }; + } else { + const start = + Date.parse(url.searchParams.get('startTime') ?? '') || -Infinity; + const end = Date.parse(url.searchParams.get('endTime') ?? '') || Infinity; + data = { + found: true, + tool_calls: [1, 50] + .filter( + (index) => + Date.parse(timestamp(index)) >= start && + Date.parse(timestamp(index)) <= end, + ) + .map((index) => ({ + id: `tool-${index}`, + timestamp: timestamp(index), + tool_name: index === 1 ? 'first_page_tool' : 'second_page_tool', + tool_source: 'native', + status: 'success', + duration: 20, + bot_id: botId, + session_id: 'person-boundary-0', + arguments: '{}', + result: '{}', + })), + }; + } + await route.fulfill({ json: { code: 0, data } }); + }); + await page.goto(`/home/bots?id=${botId}`); + await page.getByRole('tab', { name: /Sessions/ }).click(); + await expect( + page.getByRole('button', { name: /Boundary User/ }), + ).toBeVisible(); + return requests; +} + +async function selectSession(page: Page) { + await page.getByRole('button', { name: /Boundary User/ }).click(); + await expect(page.getByText('message row 0', { exact: true })).toBeVisible(); +} + +test('session pages send the server offset and reset it when the user filter is applied', async ({ + page, +}) => { + const requests = await openMonitor(page); + await page.getByRole('button', { name: 'Next', exact: true }).click(); + await expect( + page.getByRole('button', { name: /Page two User/ }), + ).toBeVisible(); + const pageRequest = requests + .filter((request) => request.path.endsWith('/sessions')) + .at(-1)!; + expect(pageRequest.query).toMatchObject({ botId, limit: '20', offset: '20' }); + expect(pageRequest.headers['x-workspace-id']).toBe('workspace-playwright'); + expect(pageRequest.headers.authorization).toBe('Bearer playwright-token'); + await page.getByRole('textbox', { name: 'User ID or name' }).fill('alice'); + await page.getByRole('textbox', { name: 'User ID or name' }).press('Enter'); + await expect + .poll( + () => + requests.filter((request) => request.path.endsWith('/sessions')).at(-1) + ?.query, + ) + .toMatchObject({ botId, limit: '20', offset: '0', userQuery: 'alice' }); + await expect( + page.getByRole('button', { name: /Boundary User/ }), + ).toBeVisible(); + await expect(page.getByRole('button', { name: /Page two User/ })).toHaveCount( + 0, + ); +}); + +test('message pages replace the visible rows with the requested bounded page', async ({ + page, +}) => { + const requests = await openMonitor(page); + await selectSession(page); + await expect(page.getByText('message row 49', { exact: true })).toHaveCount( + 1, + ); + await expect(page.getByText('message row 50', { exact: true })).toHaveCount( + 0, + ); + await page.getByRole('button', { name: 'Next page', exact: true }).click(); + await expect(page.getByText('message row 50', { exact: true })).toBeVisible(); + await expect(page.getByText('message row 51', { exact: true })).toHaveCount( + 1, + ); + await expect(page.getByText('message row 0', { exact: true })).toHaveCount(0); + await expect(page.getByText('message row 49', { exact: true })).toHaveCount( + 0, + ); + expect( + requests.filter((request) => request.path.endsWith('/messages')).at(-1) + ?.query, + ).toMatchObject({ + botId, + limit: '50', + offset: '50', + sessionId: 'person-boundary-0', + }); +}); + +for (const change of ['page', 'user', 'start date', 'end date']) { + test(`changing ${change} clears the selected session detail`, async ({ + page, + }) => { + await openMonitor(page); + await selectSession(page); + if (change === 'page') + await page.getByRole('button', { name: 'Next', exact: true }).click(); + else if (change === 'user') { + await page + .getByRole('textbox', { name: 'User ID or name' }) + .fill('alice'); + await page + .getByRole('textbox', { name: 'User ID or name' }) + .press('Enter'); + } else + await page + .getByLabel(change === 'start date' ? 'Start date' : 'End date') + .fill('2026-03-08'); + await expect( + page.getByText('Select a session to view messages', { exact: true }), + ).toBeVisible(); + await expect(page.getByText('message row 0', { exact: true })).toHaveCount( + 0, + ); + }); +} + +for (const [timezoneId, start, end] of [ + [ + 'America/Los_Angeles', + '2026-03-08T08:00:00.000Z', + '2026-03-09T06:59:59.999Z', + ], + ['Asia/Shanghai', '2026-03-07T16:00:00.000Z', '2026-03-08T15:59:59.999Z'], +]) { + test.describe(timezoneId, () => { + test.use({ timezoneId }); + test('date filters send the operator local calendar day boundaries', async ({ + page, + }) => { + const requests = await openMonitor(page); + await page.getByLabel('Start date').fill('2026-03-08'); + await page.getByLabel('End date').fill('2026-03-08'); + await expect + .poll( + () => + requests + .filter((request) => request.path.endsWith('/sessions')) + .at(-1)?.query, + ) + .toMatchObject({ startTime: start, endTime: end, offset: '0' }); + }); + }); +} + +test('tool analysis follows the visible message page rather than the previous page', async ({ + page, +}) => { + const requests = await openMonitor(page); + await selectSession(page); + await expect( + page.getByText('first_page_tool', { exact: true }), + ).toBeVisible(); + await expect(page.getByText('second_page_tool', { exact: true })).toHaveCount( + 0, + ); + await page.getByRole('button', { name: 'Next page', exact: true }).click(); + await expect( + page.getByText('second_page_tool', { exact: true }), + ).toBeVisible(); + await expect(page.getByText('first_page_tool', { exact: true })).toHaveCount( + 0, + ); + const analysis = requests + .filter((request) => request.path.endsWith('/analysis')) + .at(-1)!; + expect(analysis.path).toContain('/sessions/person-boundary-0/analysis'); + expect(analysis.query).toMatchObject({ + botId, + startTime: timestamp(50), + endTime: timestamp(51), + }); + expect(analysis.headers['x-workspace-id']).toBe('workspace-playwright'); + expect(analysis.headers.authorization).toBe('Bearer playwright-token'); +}); diff --git a/web/tests/e2e/cloud-storage-privacy.spec.ts b/web/tests/e2e/cloud-storage-privacy.spec.ts index c28cf24a0..d9c69edff 100644 --- a/web/tests/e2e/cloud-storage-privacy.spec.ts +++ b/web/tests/e2e/cloud-storage-privacy.spec.ts @@ -61,3 +61,52 @@ test('Cloud never exposes or requests storage analysis', async ({ page }) => { ); expect(storageAnalysisRequests).toBe(0); }); + +test('an OSS owner opens storage analysis from its storage-icon menu item', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + let requests = 0; + await page.route('**/api/v1/system/storage-analysis', async (route) => { + requests++; + await route.fulfill({ + json: { + code: 0, + data: { + generated_at: '2026-10-17T00:00:00Z', + total_size_bytes: 123, + sections: [], + cleanup_policy: { + uploaded_file_retention_days: 7, + log_retention_days: 14, + }, + database: { + type: 'sqlite', + monitoring_counts: {}, + binary_storage: { count: 0, size_bytes: 0 }, + }, + cleanup_candidates: { uploaded_files: [], log_files: [] }, + tasks: {}, + }, + }, + }); + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: /admin@example\.com/i }).click(); + const menuItem = page.getByRole('menuitem', { + name: 'Storage Analysis', + exact: true, + }); + await expect(menuItem).toBeVisible(); + await expect(menuItem.locator('svg.lucide-hard-drive')).toBeVisible(); + await menuItem.click(); + await expect( + page + .getByRole('dialog') + .getByRole('button', { name: 'Refresh', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('dialog').getByText('123 B', { exact: true }), + ).toBeVisible(); + await expect.poll(() => requests).toBeGreaterThan(0); +}); diff --git a/web/tests/e2e/codex-copy-feedback.spec.ts b/web/tests/e2e/codex-copy-feedback.spec.ts index 8c431db77..e5217f449 100644 --- a/web/tests/e2e/codex-copy-feedback.spec.ts +++ b/web/tests/e2e/codex-copy-feedback.spec.ts @@ -13,22 +13,7 @@ async function mount(page: Page, mode: string) { window.$RefreshReg$ = () => {}; window.$RefreshSig$ = () => (type) => type; window.__vite_plugin_react_preamble_installed__ = true; - `, + `, }), ); await page.addInitScript((mode) => { diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index ba0d3b176..9a546d2b1 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -3,6 +3,7 @@ import { expect, Page, test } from '@playwright/test'; import { installLangBotApiMocks, makeWorkspaceEntry, + pipelineMetadata, } from './fixtures/langbot-api'; async function save(page: Page) { @@ -31,11 +32,20 @@ async function createPlaywrightBot(page: Page, name: string, description = '') { await submit(page); } -async function confirmDelete(page: Page) { +async function confirmDelete(page: Page, apiPath: string) { + const responses: boolean[] = []; + page.on('response', (response) => { + if ( + response.request().method() === 'DELETE' && + new URL(response.url()).pathname === apiPath + ) + responses.push(response.ok()); + }); await page .getByRole('dialog') .getByRole('button', { name: /^Confirm Delete$/ }) .click(); + await expect.poll(() => responses).toEqual([true]); } async function installDelayedFirstSave(page: Page, apiPath: string) { @@ -198,10 +208,14 @@ test.describe('frontend CRUD smoke flows', () => { ).toBeVisible(); await page.getByRole('button', { name: /^Delete$/ }).click(); - await confirmDelete(page); + await confirmDelete(page, '/api/v1/platform/bots/bot-1'); await expect(page).toHaveURL(/\/home\/bots$/); await expect(page.getByText('Select a bot from the sidebar')).toBeVisible(); + await page.reload(); + await expect( + page.getByText('Support Bot Updated', { exact: true }), + ).toHaveCount(0); }); test('creates, edits, and deletes a pipeline', async ({ page }) => { @@ -264,15 +278,26 @@ test.describe('frontend CRUD smoke flows', () => { .getByLabel('Description') .fill('Routes urgent customer issues to operators.'); await pipelineInfoDialog.getByRole('button', { name: 'Save' }).click(); + await expect(pipelineInfoDialog).toHaveCount(0); + await page.reload(); + await page.getByRole('button', { name: 'Edit basic information' }).click(); + await expect( + page.getByRole('dialog').getByLabel('Description'), + ).toHaveValue('Routes urgent customer issues to operators.'); + await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Management' }).click(); await page.getByRole('button', { name: /^Delete$/ }).click(); - await confirmDelete(page); + await confirmDelete(page, '/api/v1/pipelines/pipeline-1'); await expect(page).toHaveURL(/\/home\/agents$/); await expect( page.getByText('Select a processor from the sidebar'), ).toBeVisible(); + await page.reload(); + await expect( + page.getByText('Escalation Pipeline', { exact: true }), + ).toHaveCount(0); }); test('opens pipeline AI capabilities with malformed model options', async ({ @@ -337,12 +362,16 @@ test.describe('frontend CRUD smoke flows', () => { ).toBeVisible(); await page.getByRole('button', { name: /^Delete$/ }).click(); - await confirmDelete(page); + await confirmDelete(page, '/api/v1/knowledge/bases/knowledge-1'); await expect(page).toHaveURL(/\/home\/knowledge$/); await expect( page.getByText('Select a knowledge base from the sidebar'), ).toBeVisible(); + await page.reload(); + await expect( + page.getByText('Support Knowledge Updated', { exact: true }), + ).toHaveCount(0); }); test('creates, edits, and deletes an MCP server', async ({ page }) => { @@ -366,18 +395,33 @@ test.describe('frontend CRUD smoke flows', () => { await page .locator('input[name="url"]') .fill('https://mcp.example.test/updated-sse'); + const update = page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + response.url().endsWith('/api/v1/mcp/servers/playwright-mcp'), + ); await save(page); + expect((await update).request().postDataJSON()).toMatchObject({ + name: 'playwright-mcp', + mode: 'remote', + extra_args: { url: 'https://mcp.example.test/updated-sse' }, + }); + await page.reload(); await expect(page.locator('input[name="url"]')).toHaveValue( 'https://mcp.example.test/updated-sse', ); await page.getByRole('button', { name: /^Delete$/ }).click(); - await confirmDelete(page); + await confirmDelete(page, '/api/v1/mcp/servers/playwright-mcp'); await expect(page).toHaveURL(/\/home\/mcp$/); await expect( page.getByText('Select an MCP server from the sidebar'), ).toBeVisible(); + await page.reload(); + await expect(page.getByText('playwright-mcp', { exact: true })).toHaveCount( + 0, + ); }); test('updates and deletes a manually-created skill', async ({ page }) => { @@ -415,9 +459,13 @@ test.describe('frontend CRUD smoke flows', () => { ); await page.getByRole('button', { name: /^Delete$/ }).click(); - await confirmDelete(page); + await confirmDelete(page, '/api/v1/skills/release_notes'); await expect(page).toHaveURL(/\/home\/add-extension$/); + await page.goto('/home/skills'); + await expect(page.getByText('Release Notes', { exact: true })).toHaveCount( + 0, + ); }); }); @@ -679,6 +727,54 @@ test.describe('bot advanced flows', () => { test('switches between bot detail tabs', async ({ page }) => { await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/platform/bots/bot-1/logs', (route) => + route.fulfill({ + json: { + code: 0, + data: { + logs: [ + { + seq_id: 1, + level: 'info', + text: 'Bot tab log response', + images: [], + message_session_id: '', + timestamp: 1792200000, + }, + ], + total_count: 1, + }, + }, + }), + ); + await page.route('**/api/v1/monitoring/sessions?**', (route) => { + expect(new URL(route.request().url()).searchParams.get('botId')).toBe( + 'bot-1', + ); + return route.fulfill({ + json: { + code: 0, + data: { + sessions: [ + { + session_id: 'person_tab-user', + bot_id: 'bot-1', + bot_name: 'Tab Test Bot', + pipeline_id: 'pipeline-tab', + pipeline_name: 'Tab pipeline', + message_count: 1, + start_time: '2026-10-17T00:00:00Z', + last_activity: '2026-10-17T00:01:00Z', + is_active: true, + user_id: 'tab-user', + user_name: 'Tab Session User', + }, + ], + total: 1, + }, + }, + }); + }); // Create a bot await page.goto('/home/bots?id=new'); @@ -699,6 +795,10 @@ test.describe('bot advanced flows', () => { 'active', ); + await expect( + page.getByText('Bot tab log response', { exact: true }), + ).toBeVisible(); + // Switch to Sessions tab await page.getByRole('tab', { name: /Sessions/ }).click(); await expect(page.getByRole('tab', { name: /Sessions/ })).toHaveAttribute( @@ -706,6 +806,10 @@ test.describe('bot advanced flows', () => { 'active', ); + await expect( + page.getByText('Tab Session User', { exact: true }), + ).toBeVisible(); + // Switch back to Configuration await page.getByRole('tab', { name: /Configuration/ }).click(); await expect( @@ -716,6 +820,14 @@ test.describe('bot advanced flows', () => { test('save button is disabled when form is clean', async ({ page }) => { await installLangBotApiMocks(page, { authenticated: true }); + await page.goto('/home/agents?id=new'); + await page.getByRole('radio', { name: /^Pipeline/ }).click(); + await page.locator('#agent-create-form').getByRole('combobox').click(); + await page.getByRole('option', { name: /Local Agent/ }).click(); + await page.locator('input[name="name"]').fill('Dirty state target'); + await submit(page); + await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/); + // Create a bot await page.goto('/home/bots?id=new'); await createPlaywrightBot(page, 'Clean Form Bot'); @@ -728,7 +840,9 @@ test.describe('bot advanced flows', () => { ).toBeVisible(); // After loading, save button should be disabled (form is clean) - const saveButton = page.getByRole('button', { name: /^Save$/ }); + const saveButton = page.getByRole('button', { + name: /^Save(?: and enable)?$/, + }); await expect(saveButton).toBeDisabled(); await page.getByRole('button', { name: 'Edit basic information' }).click(); @@ -736,6 +850,20 @@ test.describe('bot advanced flows', () => { await infoDialog.getByLabel('Description').fill('New description'); await infoDialog.getByRole('button', { name: 'Save' }).click(); await expect(saveButton).toBeDisabled(); + await page.getByRole('button', { name: 'Add behavior' }).click(); + await page.getByRole('menuitem', { name: /^Reply to messages/ }).click(); + await page + .getByRole('combobox') + .filter({ hasText: 'Select processor' }) + .click(); + await page.getByRole('option', { name: /Dirty state target/ }).click(); + await expect(saveButton).toBeEnabled(); + await save(page); + await expect(saveButton).toBeDisabled(); + await page.reload(); + await expect( + page.getByRole('combobox').filter({ hasText: 'Dirty state target' }), + ).toBeVisible(); }); test('shows validation error when bot name is empty', async ({ page }) => { @@ -823,6 +951,17 @@ test.describe('pipeline advanced flows', () => { test('save button reflects form dirty state', async ({ page }) => { await installLangBotApiMocks(page, { authenticated: true }); + const metadata = pipelineMetadata(); + metadata.configs[0].stages[1].config.push({ + name: 'instruction', + label: { en_US: 'Instruction', zh_Hans: 'Instruction' }, + type: 'text', + required: true, + default: 'Original instruction', + } as any); + await page.route('**/api/v1/pipelines/_/metadata', (route) => + route.fulfill({ json: { code: 0, data: metadata } }), + ); // Create a pipeline await page.goto('/home/agents?id=new'); @@ -842,6 +981,18 @@ test.describe('pipeline advanced flows', () => { page.getByRole('heading', { name: /Dirty Form Pipeline Updated/ }), ).toBeVisible(); await expect(saveButton).toBeDisabled(); + await page.getByRole('tab', { name: /^AI$/ }).click(); + const instruction = page.locator('textarea[name="instruction"]'); + await expect(instruction).toHaveValue('Original instruction'); + await instruction.fill('Updated instruction'); + await expect(saveButton).toBeEnabled(); + await save(page); + await expect(saveButton).toBeDisabled(); + await page.reload(); + await page.getByRole('tab', { name: /^AI$/ }).click(); + await expect(page.locator('textarea[name="instruction"]')).toHaveValue( + 'Updated instruction', + ); }); test('shows validation error when pipeline name is empty', async ({ @@ -865,253 +1016,329 @@ test.describe('pipeline advanced flows', () => { }); test.describe('agent runner resource selectors', () => { - test('installs an Runner from the grouped empty selector and refreshes it', async ({ - page, - }) => { - await installLangBotApiMocks(page, { authenticated: true }); + for (const kind of ['agent', 'pipeline'] as const) { + test(`installs an Runner from the grouped empty selector and refreshes it in ${kind}`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); - const runnerId = 'plugin:qa/MarketplaceRunner/default'; - let installed = false; - let installRequests = 0; - let taskPolls = 0; - let marketplaceSearchBody: Record | undefined; + const runnerId = 'plugin:qa/MarketplaceRunner/default'; + let installed = false; + let finished = false; + let installRequests = 0; + let taskPolls = 0; + let marketplaceSearchBody: Record | undefined; - const apiResponse = (data: unknown) => - JSON.stringify({ - code: 0, - message: 'ok', - data, - timestamp: Date.now(), - }); - const runnerConfig = () => ({ - name: 'ai', - label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' }, - stages: [ - { - name: 'runner', - label: { en_US: 'Runtime', zh_Hans: '运行方式' }, - config: [ - { - name: 'id', - label: { en_US: 'Runner', zh_Hans: '运行器' }, - type: 'select', - required: true, - default: '', - options: installed - ? [ - { - name: runnerId, - label: { - en_US: 'Marketplace Runner', - zh_Hans: '市场运行器', - }, - }, - ] - : [], - }, - ], - }, - ...(installed - ? [ + const apiResponse = (data: unknown) => + JSON.stringify({ + code: 0, + message: 'ok', + data, + timestamp: Date.now(), + }); + const runnerConfig = () => ({ + name: 'ai', + label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' }, + stages: [ + { + name: 'runner', + label: { en_US: 'Runtime', zh_Hans: '运行方式' }, + config: [ { - name: runnerId, - label: { - en_US: 'Marketplace Runner', - zh_Hans: '市场运行器', - }, - config: [], + name: 'id', + label: { en_US: 'Runner', zh_Hans: '运行器' }, + type: 'select', + required: true, + default: '', + options: installed + ? [ + { + name: runnerId, + label: { + en_US: 'Marketplace Runner', + zh_Hans: '市场运行器', + }, + }, + ] + : [], }, - ] - : []), - ], - }); - - await page.route('**/api/v1/agents/_/metadata', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: apiResponse({ - runner_config: runnerConfig(), - kinds: [], - }), - }), - ); - await page.route('**/api/v1/agents/agent-empty', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: apiResponse({ - agent: { - uuid: 'agent-empty', - name: 'Empty Runner Agent', - description: '', - emoji: 'A', - kind: 'agent', - config: { - runner: { id: '', 'expire-time': 0 }, - runner_config: {}, - }, - supported_event_patterns: ['*'], + ], }, - }), - }), - ); - await page.route('**/api/v1/pipelines/_/metadata', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: apiResponse({ configs: [runnerConfig()] }), - }), - ); - await page.route('**/api/v1/plugins', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: apiResponse({ - plugins: installed + ...(installed ? [ { - manifest: { - manifest: { - metadata: { author: 'qa', name: 'MarketplaceRunner' }, - }, + name: runnerId, + label: { + en_US: 'Marketplace Runner', + zh_Hans: '市场运行器', }, + config: [], }, ] - : [], - }), - }), - ); - await page.route('**/api/v1/plugins/install/marketplace', (route) => { - installRequests += 1; - installed = true; - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: apiResponse({ task_id: 77 }), + : []), + ], }); - }); - await page.route('**/api/v1/system/tasks/77', (route) => { - taskPolls += 1; - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: apiResponse({ - id: 77, - name: 'plugin-install-marketplace', - label: 'Marketplace Runner', - runtime: { done: true, exception: null }, - task_context: { current_action: 'complete', metadata: {} }, - }), - }); - }); - await page.route( - 'https://space.langbot.app/api/v1/marketplace/extensions/search', - async (route) => { - marketplaceSearchBody = JSON.parse( - route.request().postData() || '{}', - ) as Record; - await route.fulfill({ + + await page.route('**/api/v1/agents/_/metadata', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: apiResponse({ - extensions: [ - { - id: 1, - plugin_id: 'qa/MarketplaceRunner', - author: 'qa', - name: 'MarketplaceRunner', - label: { - en_US: 'Marketplace Runner', - zh_Hans: '市场运行器', - }, - description: { - en_US: 'Runner used by the grouped selector test.', - zh_Hans: '用于分组选择器测试的运行器。', - }, - icon: '', - repository: 'https://example.test/runner', - tags: [], - install_count: 12, - latest_version: '1.0.0', - components: { Runner: 1 }, - runner_usages: ['agent'], - status: 'live', - type: 'plugin', - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z', + runner_config: runnerConfig(), + kinds: [], + }), + }), + ); + await page.route('**/api/v1/agents/agent-empty', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: apiResponse({ + agent: { + uuid: 'agent-empty', + name: 'Empty Runner Agent', + description: '', + emoji: 'A', + kind: 'agent', + config: { + runner: { id: '', 'expire-time': 0 }, + runner_config: {}, }, - ], - total: 1, + supported_event_patterns: ['*'], + }, + }), + }), + ); + await page.route('**/api/v1/pipelines/_/metadata', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: apiResponse({ configs: [runnerConfig()] }), + }), + ); + await page.route('**/api/v1/plugins', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: apiResponse({ + plugins: installed + ? [ + { + manifest: { + manifest: { + metadata: { + author: 'qa', + name: 'MarketplaceRunner', + description: { + en_US: 'Installed plugin description', + }, + }, + }, + }, + }, + ] + : [], + }), + }), + ); + await page.route('**/api/v1/plugins/install/marketplace', (route) => { + installRequests += 1; + installed = true; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: apiResponse({ task_id: 77 }), + }); + }); + await page.route('**/api/v1/system/tasks/77', (route) => { + taskPolls += 1; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: apiResponse({ + id: 77, + name: 'plugin-install-marketplace', + label: 'Marketplace Runner', + runtime: { done: finished, exception: null }, + task_context: { current_action: 'complete', metadata: {} }, }), }); - }, - ); - - await page.goto('/home/agents?id=agent-empty'); - await page.getByRole('tab', { name: /^Runner$/ }).click(); - - const runnerSelect = page.getByRole('combobox', { name: 'Runner' }); - const triggerBox = await runnerSelect.boundingBox(); - await runnerSelect.click(); - await expect(page.getByText('Installed Runners')).toBeVisible(); - await expect( - page.getByText('No Runner extension is installed yet.'), - ).toBeVisible(); - await expect(page.getByText('Runner plugins in Marketplace')).toBeVisible(); - const selectorPopup = page.locator('[data-slot="select-content"]'); - await expect( - selectorPopup.getByText('Runner used by the grouped selector test.', { - exact: true, - }), - ).toBeVisible(); - const popupBox = await selectorPopup.boundingBox(); - expect(triggerBox?.width).toBeLessThanOrEqual(353); - expect(popupBox?.width ?? 0).toBeLessThanOrEqual( - (triggerBox?.width ?? 0) + 1, - ); - expect(popupBox?.width ?? 0).toBeGreaterThan((triggerBox?.width ?? 0) - 20); - await expect - .poll(() => marketplaceSearchBody) - .toMatchObject({ - component_filter: 'Runner', - type_filter: 'plugin', }); + await page.route( + 'https://space.langbot.app/api/v1/marketplace/extensions/search', + async (route) => { + marketplaceSearchBody = JSON.parse( + route.request().postData() || '{}', + ) as Record; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: apiResponse({ + extensions: [ + { + id: 1, + plugin_id: 'qa/MarketplaceRunner', + author: 'qa', + name: 'MarketplaceRunner', + label: { + en_US: 'Marketplace Runner', + zh_Hans: '市场运行器', + }, + description: { + en_US: 'Runner used by the grouped selector test.', + zh_Hans: '用于分组选择器测试的运行器。', + }, + icon: '', + repository: 'https://example.test/runner', + tags: [], + install_count: 12, + latest_version: '1.0.0', + components: { Runner: 1 }, + runner_usages: ['agent'], + status: 'live', + type: 'plugin', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + { + author: 'qa', + name: 'SecondRunner', + label: { en_US: 'A much longer second Runner label' }, + description: { en_US: 'Second runner description' }, + latest_version: '1.0.0', + components: { Runner: 1 }, + runner_usages: ['agent'], + install_count: 1, + type: 'plugin', + }, + ].filter( + (plugin) => !installed || plugin.name !== 'MarketplaceRunner', + ), + total: installed ? 1 : 2, + }), + }); + }, + ); - await page - .getByRole('button', { name: 'Install Marketplace Runner', exact: true }) - .click(); + await page.route('**/api/v1/pipelines/agent-empty', (route) => + route.fulfill({ + json: { + code: 0, + data: { + pipeline: { + uuid: 'agent-empty', + kind: 'pipeline', + name: 'Empty Runner Pipeline', + description: '', + emoji: 'P', + is_default: false, + config: { + ai: { + runner: { id: '', 'expire-time': 0 }, + runner_config: {}, + }, + trigger: {}, + safety: {}, + output: {}, + }, + }, + }, + }, + }), + ); + await page.goto( + kind === 'agent' + ? '/home/agents?id=agent-empty' + : '/home/pipelines?id=agent-empty', + ); + await page + .getByRole('tab', { name: kind === 'agent' ? /^Runner$/ : /^AI$/ }) + .click(); - await expect.poll(() => installRequests).toBe(1); - await expect.poll(() => taskPolls).toBeGreaterThan(0); - await expect( - page.getByRole('option', { - name: 'Marketplace Runner Runner used by the grouped selector test.', - exact: true, - }), - ).toBeVisible(); - await page - .getByRole('option', { - name: 'Marketplace Runner Runner used by the grouped selector test.', - exact: true, - }) - .click(); - await expect(runnerSelect).toContainText('Marketplace Runner'); + const runnerSelect = page.getByRole('combobox', { + name: 'Runner', + includeHidden: true, + }); + const triggerBox = await runnerSelect.boundingBox(); + await runnerSelect.click(); + await expect(page.getByText('Installed Runners')).toBeVisible(); + await expect( + page.getByText('No Runner extension is installed yet.'), + ).toBeVisible(); + await expect( + page.getByText('Runner plugins in Marketplace'), + ).toBeVisible(); + const selectorPopup = page.locator('[data-slot="select-content"]'); + await expect( + selectorPopup.getByText('Runner used by the grouped selector test.', { + exact: true, + }), + ).toBeVisible(); + const popupBox = await selectorPopup.boundingBox(); + expect(triggerBox?.width).toBeLessThanOrEqual(353); + expect(popupBox?.width ?? 0).toBeLessThanOrEqual( + (triggerBox?.width ?? 0) + 1, + ); + expect(popupBox?.width ?? 0).toBeGreaterThan( + (triggerBox?.width ?? 0) - 20, + ); + await expect + .poll(() => marketplaceSearchBody) + .toMatchObject({ + component_filter: 'Runner', + type_filter: 'plugin', + }); - await runnerSelect.click(); - await expect( - page.getByRole('option', { - name: 'Marketplace Runner Runner used by the grouped selector test.', - exact: true, - }), - ).toBeVisible(); - await expect( - page.getByRole('button', { + const install = page.getByRole('button', { name: 'Install Marketplace Runner', exact: true, - }), - ).toHaveCount(0); - }); + }); + const otherInstall = page.getByRole('button', { + name: 'Install A much longer second Runner label', + exact: true, + }); + const positions = await Promise.all([ + install.boundingBox(), + otherInstall.boundingBox(), + ]); + expect(positions.every(Boolean)).toBe(true); + expect(Math.abs(positions[0]!.x - positions[1]!.x)).toBeLessThanOrEqual( + 1, + ); + expect( + Math.abs(positions[0]!.width - positions[1]!.width), + ).toBeLessThanOrEqual(1); + await install.click(); + await expect(install).toContainText('%'); + await expect(otherInstall).toBeDisabled(); + await expect(runnerSelect).not.toContainText('Marketplace Runner'); + finished = true; + + await expect.poll(() => installRequests).toBe(1); + await expect.poll(() => taskPolls).toBeGreaterThan(0); + await expect( + page.getByRole('option', { + name: 'Marketplace Runner Installed plugin description', + exact: true, + }), + ).toBeVisible(); + await expect(runnerSelect).not.toContainText('Marketplace Runner'); + await expect( + page.getByRole('button', { + name: 'Install Marketplace Runner', + exact: true, + }), + ).toHaveCount(0); + await expect(otherInstall).toBeEnabled(); + await page + .getByRole('option', { + name: 'Marketplace Runner Installed plugin description', + exact: true, + }) + .click(); + await expect(runnerSelect).toContainText('Marketplace Runner'); + }); + } test('uses the compact Runner marketplace selector in pipeline AI settings', async ({ page, @@ -1464,39 +1691,3 @@ test.describe('cross-resource flows', () => { ).toBeVisible(); }); }); - -test.describe('empty states', () => { - test('shows empty state when no bots exist', async ({ page }) => { - await installLangBotApiMocks(page, { authenticated: true }); - - await page.goto('/home/bots'); - await expect(page.getByText('Select a bot from the sidebar')).toBeVisible(); - }); - - test('shows empty state when no processors exist', async ({ page }) => { - await installLangBotApiMocks(page, { authenticated: true }); - - await page.goto('/home/agents'); - await expect( - page.getByText('Select a processor from the sidebar'), - ).toBeVisible(); - }); - - test('shows empty state when no knowledge bases exist', async ({ page }) => { - await installLangBotApiMocks(page, { authenticated: true }); - - await page.goto('/home/knowledge'); - await expect( - page.getByText('Select a knowledge base from the sidebar'), - ).toBeVisible(); - }); - - test('shows empty state when no MCP servers exist', async ({ page }) => { - await installLangBotApiMocks(page, { authenticated: true }); - - await page.goto('/home/mcp'); - await expect( - page.getByText('Select an MCP server from the sidebar'), - ).toBeVisible(); - }); -}); diff --git a/web/tests/e2e/debug-socket-ownership.spec.ts b/web/tests/e2e/debug-socket-ownership.spec.ts new file mode 100644 index 000000000..2af145804 --- /dev/null +++ b/web/tests/e2e/debug-socket-ownership.spec.ts @@ -0,0 +1,176 @@ +import { createHash } from 'node:crypto'; +import { createServer as createHttpServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Duplex } from 'node:stream'; +import { createServer as createViteServer } from 'vite'; +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// A small native wire fixture lets the server delay the close acknowledgement. +// routeWebSocket closes a mocked transport before that browser lifecycle boundary. +function frame(opcode: number, payload: Buffer) { + const header = Buffer.alloc(payload.length < 126 ? 2 : 4); + header[0] = 0x80 | opcode; + header[1] = payload.length < 126 ? payload.length : 126; + if (payload.length >= 126) header.writeUInt16BE(payload.length, 2); + return Buffer.concat([header, payload]); +} + +function receiveFrames(socket: Duplex, onText: (text: string) => void) { + let buffered = Buffer.alloc(0); + socket.on('data', (chunk: Buffer) => { + buffered = Buffer.concat([buffered, chunk]); + while (buffered.length >= 2) { + const opcode = buffered[0] & 0x0f; + let length = buffered[1] & 0x7f; + let offset = 2; + if (length === 126) { + if (buffered.length < 4) return; + length = buffered.readUInt16BE(2); + offset = 4; + } + if (length === 127) throw new Error('Unexpected large fixture frame'); + if (buffered.length < offset + 4 + length) return; + const mask = buffered.subarray(offset, offset + 4); + const payload = Buffer.from( + buffered.subarray(offset + 4, offset + 4 + length), + ); + buffered = buffered.subarray(offset + 4 + length); + for (let index = 0; index < payload.length; index++) + payload[index] ^= mask[index % 4]; + if (opcode === 8) socket.end(frame(8, payload)); + else if (opcode === 1) onText(payload.toString()); + } + }); +} + +test('a cancelled native handshake cannot disable the replacement debug session', async ({ + page, +}, testInfo) => { + const sockets = new Set(); + const authenticated: string[] = []; + const failures: string[] = []; + let pendingPerson: Duplex | undefined; + let oldNativeClosed = false; + let oldBrowserSocket: import('@playwright/test').WebSocket | undefined; + const sent: unknown[] = []; + const server = createHttpServer(); + server.on('upgrade', (request, socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + const key = request.headers['sec-websocket-key']; + if (typeof key !== 'string') throw new Error('Missing fixture upgrade key'); + const accept = createHash('sha1') + .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') + .digest('base64'); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + const session = new URL(request.url!, 'http://fixture').searchParams.get( + 'session_type', + )!; + const send = (value: unknown) => + socket.write(frame(1, Buffer.from(JSON.stringify(value)))); + receiveFrames(socket, (text) => { + const message = JSON.parse(text); + if (message.type === 'authenticate') { + authenticated.push(session); + if (session === 'person') { + pendingPerson = socket; + // Authentication remains pending. Stop reading so the client's close + // frame cannot be acknowledged until the group session is ready. + socket.pause(); + } else + send({ + type: 'connected', + connection_id: 'current-group', + pipeline_uuid: 'pipeline-workbench', + session_type: 'group', + }); + } else if (message.type === 'message') { + sent.push(message.message); + send({ + type: 'response', + session_type: 'group', + data: { + id: 31, + role: 'assistant', + content: 'Native group reply', + message_chain: [{ type: 'Plain', text: 'Native group reply' }], + timestamp: new Date().toISOString(), + is_final: true, + }, + }); + } + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const backendPort = (server.address() as AddressInfo).port; + let vite: Awaited> | undefined; + try { + vite = await createViteServer({ + cacheDir: testInfo.outputPath('vite-cache'), + server: { + host: '127.0.0.1', + port: 0, + proxy: { + '/api': { + target: `http://127.0.0.1:${backendPort}`, + changeOrigin: true, + ws: true, + }, + }, + }, + }); + await vite.listen(); + const frontendPort = (vite.httpServer!.address() as AddressInfo).port; + page.on('console', (message) => { + if ( + message.type() === 'error' && + message.text().startsWith('WebSocket connection failed:') + ) + failures.push(message.text()); + }); + page.on('websocket', (socket) => { + if (new URL(socket.url()).searchParams.get('session_type') !== 'person') + return; + socket.on('framesent', (event) => { + if (JSON.parse(String(event.payload)).type === 'authenticate') + oldBrowserSocket = socket; + }); + socket.on('close', () => { + if (socket === oldBrowserSocket) oldNativeClosed = true; + }); + }); + await installLangBotApiMocks(page, { authenticated: true }); + await page.goto( + `http://127.0.0.1:${frontendPort}/home/pipelines?id=pipeline-workbench`, + ); + const debug = page.getByRole('region', { name: 'Debug Chat' }); + await expect.poll(() => authenticated).toEqual(['person']); + await debug.getByRole('button', { name: 'Group Chat' }).click(); + await expect.poll(() => authenticated).toEqual(['person', 'group']); + await expect(debug.getByText('Connected', { exact: true })).toBeVisible(); + expect(oldNativeClosed).toBe(false); + failures.length = 0; + pendingPerson!.resume(); + await expect.poll(() => oldNativeClosed).toBe(true); + await expect(debug.getByText('Connected', { exact: true })).toBeVisible(); + await debug + .locator('[data-debug-composer="true"] textarea') + .fill('Message after native close'); + await debug.getByRole('button', { name: 'Send', exact: true }).click(); + await expect + .poll(() => sent) + .toEqual([[{ type: 'Plain', text: 'Message after native close' }]]); + await expect(debug.getByText('Native group reply')).toBeVisible(); + expect(failures).toEqual([]); + await expect( + page.getByText('WebSocket connection failed', { exact: true }), + ).toHaveCount(0); + } finally { + for (const socket of sockets) socket.destroy(); + await vite?.close(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/web/tests/e2e/entity-loading.spec.ts b/web/tests/e2e/entity-loading.spec.ts index 27a7f190e..0f051d3a0 100644 --- a/web/tests/e2e/entity-loading.spec.ts +++ b/web/tests/e2e/entity-loading.spec.ts @@ -22,7 +22,7 @@ async function setup(page: Page) { await page.route('**/api/v1/plugins/qa/loading**', async (route) => { const path = new URL(route.request().url()).pathname; const data = path.endsWith('/config') - ? { config: {} } + ? { config: { support_text: 'Restored plugin configuration' } } : path.endsWith('/readme') ? { readme: '# Loaded documentation' } : { @@ -35,7 +35,16 @@ async function setup(page: Page) { label: { en_US: 'Loading test plugin' }, description: { en_US: 'Test' }, }, - spec: { config: [] }, + spec: { + config: [ + { + name: 'support_text', + label: { en_US: 'Plugin greeting' }, + type: 'text', + default: '', + }, + ], + }, }, }, components: [], @@ -179,8 +188,8 @@ const cases = [ name: 'plugin configuration', url: '/home/extensions?id=qa/loading', endpoint: '/plugins/qa/loading/config', - form: '[data-slot="card-title"]', - readyText: 'Plugin Configuration', + form: 'textarea[name="support_text"]', + readyValue: 'Restored plugin configuration', }, ]; @@ -225,7 +234,9 @@ for (const scenario of cases) { test(`${scenario.name}: failed request can be retried`, async ({ page }) => { await setup(page); let fail = true; + let requests = 0; await page.route(`**/api/v1${scenario.endpoint}`, async (route) => { + requests++; if (fail) await route.fulfill({ status: 503, @@ -242,9 +253,19 @@ for (const scenario of cases) { .getByRole('alert') .filter({ hasText: 'Unable to load this content' }); await expect(error).toBeVisible(); + const requestsBeforeRetry = requests; fail = false; await error.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect.poll(() => requests).toBeGreaterThan(requestsBeforeRetry); await expect(error).toHaveCount(0); + if (scenario.readyText) + await expect( + page.getByText(scenario.readyText, { exact: true }).first(), + ).toBeVisible(); + if (scenario.readyValue) + await expect(page.locator(scenario.form)).toHaveValue( + scenario.readyValue, + ); await expect(page.locator(scenario.form).first()).toBeAttached(); await expect( page.getByRole('status').filter({ hasText: 'Loading...' }), diff --git a/web/tests/e2e/fixtures/codex-copy-harness.mjs b/web/tests/e2e/fixtures/codex-copy-harness.mjs new file mode 100644 index 000000000..c8f277ed9 --- /dev/null +++ b/web/tests/e2e/fixtures/codex-copy-harness.mjs @@ -0,0 +1,45 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { Toaster } from '/src/components/ui/sonner.tsx'; +import '/src/app/global.css'; +import { + Dialog, + DialogContent, + DialogTitle, +} from '/src/components/ui/dialog.tsx'; +import Section from '/src/app/home/components/models-dialog/component/provider-form/CodexAccountSection.tsx'; +i18n.use(initReactI18next); +await i18n.init({ + lng: 'en', + resources: { en: { translation: {} } }, + interpolation: { escapeValue: false }, +}); +const root = createRoot(document.getElementById('root')); +window.renderCode = (code = 'FIXTURE-1234', attempt = 'attempt-1') => + root.render( + React.createElement( + Dialog, + { open: true }, + React.createElement( + DialogContent, + {}, + React.createElement(DialogTitle, {}, 'Copy fixture'), + React.createElement(Section, { + providerId: 'fixture', + login: { + phase: 'pending', + device: { + user_code: code, + authorization_id: attempt, + verification_uri: 'https://example.invalid', + expires_at: 9999999999, + }, + }, + }), + ), + React.createElement(Toaster), + ), + ); +window.renderCode(); diff --git a/web/tests/e2e/home-smoke.spec.ts b/web/tests/e2e/home-smoke.spec.ts index 3f7b808ed..dbc19d85d 100644 --- a/web/tests/e2e/home-smoke.spec.ts +++ b/web/tests/e2e/home-smoke.spec.ts @@ -138,3 +138,37 @@ test.describe('authenticated app shell', () => { ); }); }); + +test('token monitoring displays structured API failures and retries the request', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + let failed = true; + let requests = 0; + await page.route( + '**/api/v1/monitoring/token-statistics?**', + async (route) => { + requests++; + if (failed) + return route.fulfill({ + status: 503, + json: { code: 1, msg: 'Token aggregation unavailable' }, + }); + await route.fallback(); + }, + ); + await page.goto('/home/monitoring'); + await page.getByRole('tab', { name: 'Token Monitoring' }).click(); + const error = page + .getByRole('alert') + .filter({ hasText: 'Token aggregation unavailable' }); + await expect(error).toBeVisible(); + const beforeRetry = requests; + failed = false; + await error.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect.poll(() => requests).toBeGreaterThan(beforeRetry); + await expect(error).toHaveCount(0); + await expect( + page.getByText('No token usage in the selected time range'), + ).toBeVisible(); +}); diff --git a/web/tests/e2e/invitations.spec.ts b/web/tests/e2e/invitations.spec.ts index b9f77cfb4..ccf3908f8 100644 --- a/web/tests/e2e/invitations.spec.ts +++ b/web/tests/e2e/invitations.spec.ts @@ -214,6 +214,7 @@ test('an OAuth-only OSS instance registers the invited email with a local passwo email: 'invited@example.com', password: 'invite-password-123', }); + expect(await page.evaluate(() => localStorage.getItem('token'))).toBeNull(); }); test('an authenticated OSS invitation requires logout before registration', async ({ @@ -402,8 +403,10 @@ test('Space OAuth accepts a pending invitation with the freshly authenticated ac }); let acceptanceAuthorization = ''; + let acceptanceBody: unknown; await page.route('**/api/v1/invitations/accept', async (route) => { acceptanceAuthorization = route.request().headers().authorization ?? ''; + acceptanceBody = route.request().postDataJSON(); await route.fulfill({ status: 200, contentType: 'application/json', @@ -424,6 +427,7 @@ test('Space OAuth accepts a pending invitation with the freshly authenticated ac timeout: 5_000, }); expect(acceptanceAuthorization).toBe('Bearer fresh-invited-account-token'); + expect(acceptanceBody).toEqual({ token: 'matching-invitation' }); expect( await page.evaluate(() => ({ token: localStorage.getItem('token'), diff --git a/web/tests/e2e/knowledge-engine-marketplace.spec.ts b/web/tests/e2e/knowledge-engine-marketplace.spec.ts new file mode 100644 index 000000000..032d10ff3 --- /dev/null +++ b/web/tests/e2e/knowledge-engine-marketplace.spec.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +for (const reload of [false, true]) { + test(`knowledge engine installation keeps fields and requires explicit selection${reload ? ' after reload' : ''}`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + let finished = false, + installs = 0; + const searches: Record[] = []; + const engine = { + plugin_id: 'qa/engine', + name: { en_US: 'Installed QA Engine' }, + description: { en_US: 'Registered knowledge engine description' }, + creation_schema: [], + retrieval_schema: [], + capabilities: [], + }; + await page.route('**/api/v1/knowledge/engines', (route) => + route.fulfill({ + json: { code: 0, data: { engines: finished ? [engine] : [] } }, + }), + ); + await page.route('**/api/v1/marketplace/**/search', async (route) => { + searches.push(route.request().postDataJSON()); + const plugin = (name: string, label: string) => ({ + author: 'qa', + name, + label: { en_US: label }, + description: { en_US: `${label} description` }, + latest_version: '2.0.0', + type: 'plugin', + components: { KnowledgeEngine: 1 }, + install_count: 10, + }); + await route.fulfill({ + json: { + code: 0, + data: { + total: 2, + extensions: [ + plugin('engine', 'QA Engine'), + plugin('long-engine', 'A much longer engine display label'), + ], + }, + }, + }); + }); + await page.route('**/api/v1/plugins/install/marketplace', async (route) => { + installs++; + expect(route.request().postDataJSON()).toEqual({ + plugin_author: 'qa', + plugin_name: 'engine', + plugin_version: '2.0.0', + }); + await route.fulfill({ json: { code: 0, data: { task_id: 42 } } }); + }); + await page.route('**/api/v1/system/tasks/42', (route) => + route.fulfill({ + json: { + code: 0, + data: { + id: 42, + name: 'plugin-install-marketplace', + runtime: { done: finished, exception: null }, + task_context: { + current_action: 'Downloading', + metadata: { progress_percent: 20 }, + }, + }, + }, + }), + ); + await page.goto('/home/knowledge?id=new'); + await page.locator('input[name="name"]').fill('Engine choice test'); + await expect(page.locator('input[name="description"]')).toBeVisible(); + const selector = page.getByRole('combobox', { + name: 'Knowledge Engine', + exact: true, + }); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect( + page.getByText('Knowledge engine is required', { exact: true }), + ).toBeVisible(); + await selector.click(); + const first = page.getByRole('button', { + name: 'Install QA Engine', + exact: true, + }); + const second = page.getByRole('button', { + name: 'Install A much longer engine display label', + exact: true, + }); + await expect(first).toBeVisible(); + const positions = await Promise.all([ + first.boundingBox(), + second.boundingBox(), + ]); + expect(positions.every(Boolean)).toBe(true); + expect(Math.abs(positions[0]!.x - positions[1]!.x)).toBeLessThanOrEqual(1); + expect( + Math.abs(positions[0]!.width - positions[1]!.width), + ).toBeLessThanOrEqual(1); + await first.click(); + await expect(first).toContainText('%'); + await expect(second).toBeDisabled(); + if (reload) { + await expect.poll(() => installs).toBe(1); + await page.reload(); + await expect(page.locator('input[name="name"]')).toBeVisible(); + await page.locator('input[name="name"]').fill('Recovered engine choice'); + await expect(selector).toHaveText('Select Knowledge Engine'); + } + finished = true; + await expect( + page.getByText('QA Engine is installed and ready to select', { + exact: true, + }), + ).toBeVisible({ timeout: 15000 }); + await page.keyboard.press('Escape'); + await expect(selector).toHaveText('Select Knowledge Engine'); + if (reload) + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect( + page.getByText('Knowledge engine is required', { exact: true }), + ).toBeVisible(); + await selector.click(); + const option = page.getByRole('option', { name: /Installed QA Engine/ }); + await expect( + option.getByText('Registered knowledge engine description'), + ).toBeVisible(); + await option.click(); + await expect(selector).toHaveText('Installed QA Engine'); + await expect( + page.getByText('Knowledge engine is required', { exact: true }), + ).toHaveCount(0); + const selectedBox = await selector.boundingBox(); + expect(selectedBox!.height).toBeLessThanOrEqual(40); + expect(installs).toBe(1); + expect(searches.length).toBeGreaterThan(0); + for (const request of searches) + expect(request).toMatchObject({ + component_filter: 'KnowledgeEngine', + type_filter: 'plugin', + }); + }); +} diff --git a/web/tests/e2e/login.spec.ts b/web/tests/e2e/login.spec.ts index e60c78574..b2e3c964d 100644 --- a/web/tests/e2e/login.spec.ts +++ b/web/tests/e2e/login.spec.ts @@ -126,6 +126,87 @@ test('Space OAuth bootstraps a singleton before entering home', async ({ expect(selectedWorkspaceHeaders).toContain('workspace-playwright'); }); +test('support-admin launch keeps its Workspace scope without Account bootstrap', async ({ + page, +}) => { + const workspaceUuid = 'workspace-support-admin'; + const currentWorkspaceRequests: { + workspace?: string; + authorization?: string; + }[] = []; + let accountBootstrapRequests = 0; + page.on('request', (request) => { + if (new URL(request.url()).pathname === '/api/v1/workspaces/current') { + currentWorkspaceRequests.push({ + workspace: request.headers()['x-workspace-id'], + authorization: request.headers().authorization, + }); + } + }); + await installLangBotApiMocks(page, { + authenticated: true, + storage: { + langbot_active_workspace_uuid: 'workspace-from-another-account', + }, + workspaces: [makeWorkspaceEntry(workspaceUuid, 'Support Workspace')], + }); + await page.route('**/api/v1/user/space/callback', (route) => + route.fulfill({ + json: { + code: 0, + data: { + token: 'support-admin-token', + principal_type: 'support_admin', + workspace_uuid: workspaceUuid, + }, + }, + }), + ); + await page.route('**/api/v1/workspaces/bootstrap', (route) => { + accountBootstrapRequests += 1; + return route.fulfill({ status: 403, json: { code: 403 } }); + }); + await page.route('**/api/v1/system/info', (route) => + route.fulfill({ + json: { + code: 0, + data: { + edition: 'cloud', + cloud_service_url: 'https://space.langbot.app', + wizard_status: 'none', + limitation: { max_bots: -1, max_pipelines: -1, max_extensions: -1 }, + }, + }, + }), + ); + + await page.goto( + `/auth/space/callback#workspace_uuid=${workspaceUuid}&launch_assertion=support-launch`, + ); + + await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/); + await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible(); + expect(currentWorkspaceRequests.length).toBeGreaterThan(0); + for (const request of currentWorkspaceRequests) { + expect(request).toEqual({ + workspace: workspaceUuid, + authorization: 'Bearer support-admin-token', + }); + } + expect(accountBootstrapRequests).toBe(0); + expect( + await page.evaluate(() => ({ + token: localStorage.getItem('token'), + principalType: localStorage.getItem('authPrincipalType'), + workspace: localStorage.getItem('langbot_active_workspace_uuid'), + })), + ).toEqual({ + token: 'support-admin-token', + principalType: 'support_admin', + workspace: workspaceUuid, + }); +}); + test('Space OAuth sends a multi-Workspace Account to the chooser', async ({ page, }) => { diff --git a/web/tests/e2e/marketplace-likes.spec.ts b/web/tests/e2e/marketplace-likes.spec.ts new file mode 100644 index 000000000..49a7e51f4 --- /dev/null +++ b/web/tests/e2e/marketplace-likes.spec.ts @@ -0,0 +1,89 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +test('marketplace defaults to hot sorting and updates the same browser like on its card', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + const searches: Record[] = [], + updates: unknown[] = []; + let fingerprint = ''; + await page.route('**/api/v1/marketplace/extensions/search', async (route) => { + searches.push(route.request().postDataJSON()); + await route.fulfill({ + json: { + code: 0, + data: { + total: 1, + extensions: [ + { + author: 'qa', + name: 'liked', + label: { en_US: 'QA Likes' }, + description: { en_US: 'Like test plugin' }, + latest_version: '1.0.0', + type: 'plugin', + status: 'live', + tags: [], + components: {}, + install_count: 5, + like_count: 12, + hot_score: 17, + }, + ], + }, + }, + }); + }); + await page.route( + '**/api/v1/marketplace/extensions/likes/status', + async (route) => { + fingerprint = route.request().postDataJSON().fingerprint; + expect(fingerprint).toEqual(expect.any(String)); + expect(fingerprint.length).toBeGreaterThan(0); + await route.fulfill({ json: { code: 0, data: { extensions: [] } } }); + }, + ); + await page.route( + '**/api/v1/marketplace/extensions/plugin/qa/liked/like', + async (route) => { + const body = route.request().postDataJSON(); + updates.push(body); + expect(route.request().method()).toBe('PUT'); + expect(body.fingerprint).toBe(fingerprint); + await route.fulfill({ + json: { + code: 0, + data: { + type: 'plugin', + extension_id: 'qa/liked', + liked: body.liked, + like_count: body.liked ? 13 : 12, + }, + }, + }); + }, + ); + await page.goto('/home/add-extension'); + await expect(page.getByText('QA Likes', { exact: true })).toBeVisible(); + await expect.poll(() => fingerprint).not.toBe(''); + const like = page.getByRole('button', { name: 'Like', exact: true }); + await expect(like).toHaveAttribute('aria-pressed', 'false'); + await expect(like).toHaveText('12'); + expect(searches.length).toBeGreaterThan(0); + expect(searches[0]).toMatchObject({ + sort_by: 'hot_score', + sort_order: 'DESC', + }); + await like.click(); + const unlike = page.getByRole('button', { name: 'Unlike', exact: true }); + await expect(unlike).toHaveAttribute('aria-pressed', 'true'); + await expect(unlike).toHaveText('13'); + await unlike.click(); + await expect(like).toHaveAttribute('aria-pressed', 'false'); + await expect(like).toHaveText('12'); + expect(updates).toEqual([ + { fingerprint, liked: true }, + { fingerprint, liked: false }, + ]); +}); diff --git a/web/tests/e2e/monitoring-executions.spec.ts b/web/tests/e2e/monitoring-executions.spec.ts index 93a1992c6..9213d04f9 100644 --- a/web/tests/e2e/monitoring-executions.spec.ts +++ b/web/tests/e2e/monitoring-executions.spec.ts @@ -646,9 +646,20 @@ test('remembers all filters on reload and persists resetting them', async ({ for (const [id, label] of selections) { await page.locator(`#monitoring-filter-${id}`).click(); if (id === 'processor') { - await page.getByRole('checkbox', { name: label, exact: true }).check(); + const checkbox = page.getByRole('dialog').getByRole('checkbox', { + name: label, + exact: true, + }); + await checkbox.check(); + await expect(checkbox).toBeChecked(); await page.keyboard.press('Escape'); - } else await page.getByRole('option', { name: label, exact: true }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + } else { + await page.getByRole('option', { name: label, exact: true }).click(); + await expect(page.getByRole('listbox')).toHaveCount(0); + } + // The previous popup must finish restoring focus before opening another. + await expect(page.locator(`#monitoring-filter-${id}`)).toBeFocused(); } await page.reload(); for (const [id, label] of selections) { diff --git a/web/tests/e2e/owner-space-billing.spec.ts b/web/tests/e2e/owner-space-billing.spec.ts index 4db78e3f6..60be5d490 100644 --- a/web/tests/e2e/owner-space-billing.spec.ts +++ b/web/tests/e2e/owner-space-billing.spec.ts @@ -1,64 +1,98 @@ import { expect, test } from '@playwright/test'; +import { + installLangBotApiMocks, + makeWorkspaceEntry, +} from './fixtures/langbot-api'; -import { installLangBotApiMocks } from './fixtures/langbot-api'; - -test('an OSS local-only owner is prompted to bind before using LangBot Models', async ({ - page, -}) => { - await installLangBotApiMocks(page, { authenticated: true }); - await page.route('**/api/v1/user/space-credits', (route) => - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ - code: 0, - data: { - credits: null, - owner_space_bound: false, - is_workspace_owner: true, - }, - msg: 'ok', - }), - }), - ); - await page.route('**/api/v1/provider/providers', (route) => - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ - code: 0, - data: { - providers: [ - { - uuid: 'langbot-models-provider', - name: 'LangBot Models', - requester: 'space-chat-completions', - base_url: '', - api_keys: [], +for (const role of ['owner', 'viewer'] as const) { + for (const bound of [false, true]) { + test(`LangBot Models uses Workspace billing for ${role}, owner bound=${bound}`, async ({ + page, + }) => { + const workspace = makeWorkspaceEntry( + 'billing-workspace', + 'Billing Workspace', + 'local', + ); + workspace.membership.role = role; + await installLangBotApiMocks(page, { + authenticated: true, + workspaces: [workspace], + }); + const billingHeaders: Array> = []; + await page.route('**/api/v1/user/space-credits', async (route) => { + billingHeaders.push(route.request().headers()); + await route.fulfill({ + json: { + code: 0, + data: { + credits: bound ? 12500 : null, + owner_space_bound: bound, + is_workspace_owner: role === 'owner', }, - ], - }, - msg: 'ok', - }), - }), - ); - await page.route('**/api/v1/provider/requesters**', (route) => - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ code: 0, data: { requesters: [] }, msg: 'ok' }), - }), - ); - await page.route('**/api/v1/provider/models/**', (route) => - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ code: 0, data: { models: [] }, msg: 'ok' }), - }), - ); - - await page.goto('/home?action=showModelSettings'); - - await expect( - page.getByText( - 'The Workspace owner must connect a LangBot Account for LangBot Models.', - { exact: true }, - ), - ).toBeVisible(); -}); + }, + }); + }); + await page.route('**/api/v1/provider/providers', (route) => + route.fulfill({ + json: { + code: 0, + data: { + providers: [ + { + uuid: 'langbot-models-provider', + name: 'LangBot Models', + requester: 'space-chat-completions', + base_url: '', + api_keys: [], + }, + ], + }, + }, + }), + ); + await page.route('**/api/v1/provider/requesters**', (route) => + route.fulfill({ json: { code: 0, data: { requesters: [] } } }), + ); + await page.route('**/api/v1/provider/models/**', (route) => + route.fulfill({ json: { code: 0, data: { models: [] } } }), + ); + await page.goto('/home?action=showModelSettings'); + await expect.poll(() => billingHeaders.length).toBeGreaterThan(0); + expect(billingHeaders[0]).toMatchObject({ + authorization: 'Bearer playwright-token', + 'x-workspace-id': 'billing-workspace', + }); + const dialog = page.getByRole('dialog'); + await expect( + dialog.getByText( + 'The Workspace owner must connect a LangBot Account for LangBot Models.', + { exact: true }, + ), + ).toHaveCount(bound ? 0 : 1); + await expect( + dialog.getByRole('button', { + name: 'Login with LangBot Account', + exact: true, + }), + ).toHaveCount(role === 'owner' && !bound ? 1 : 0); + await expect( + dialog.getByText('2.50 Credits', { exact: true }), + ).toHaveCount(bound ? 1 : 0); + if (bound) { + await expect( + dialog + .getByText('2.50 Credits', { exact: true }) + .locator('..') + .getByRole('button'), + ).toHaveCount(role === 'owner' ? 1 : 0); + } + await expect( + dialog.getByText( + "Uses the Workspace owner's LangBot Account billing and credits.", + { exact: true }, + ), + ).toHaveCount(role === 'viewer' && bound ? 1 : 0); + }); + } +} diff --git a/web/tests/e2e/pipeline-migration.spec.ts b/web/tests/e2e/pipeline-migration.spec.ts index d7b88e022..ab068e2bd 100644 --- a/web/tests/e2e/pipeline-migration.spec.ts +++ b/web/tests/e2e/pipeline-migration.spec.ts @@ -419,7 +419,9 @@ for (const failure of [ test('workspace changes discard previous task results and stop polling', async ({ page, }) => { + await page.clock.install(); const state = await setup(page); + state.done = false; let release!: () => void; state.holdPoll = new Promise((resolve) => { release = resolve; @@ -437,6 +439,8 @@ test('workspace changes discard previous task results and stop polling', async ( await expect( page.getByRole('dialog', { name: 'Pipeline migration' }), ).toHaveCount(0); + await page.clock.runFor(3000); + expect(state.polls).toBe(1); expect(state.posts).toHaveLength(1); }); diff --git a/web/tests/e2e/plugin-page-auth.spec.ts b/web/tests/e2e/plugin-page-auth.spec.ts index 384567120..5f12ab2f2 100644 --- a/web/tests/e2e/plugin-page-auth.spec.ts +++ b/web/tests/e2e/plugin-page-auth.spec.ts @@ -62,6 +62,7 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({ }); let authenticatedAssetRequests = 0; + const assetHeaders: Array> = []; let pageSdkRequests = 0; let pageApiRequests = 0; await page.route('**/api/v1/plugins/_sdk/page-sdk.js', async (route) => { @@ -91,6 +92,7 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({ '**/api/v1/plugins/langbot-team/LangRAG/authenticated-assets/**', async (route) => { authenticatedAssetRequests += 1; + assetHeaders.push(route.request().headers()); await route.fulfill({ status: 200, contentType: 'text/html', @@ -135,7 +137,274 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({ 'true', ); expect(authenticatedAssetRequests).toBeGreaterThan(0); + expect(assetHeaders[0]).toMatchObject({ + authorization: 'Bearer playwright-token', + 'x-workspace-id': 'workspace-cloud', + }); expect(pageSdkRequests).toBe(1); expect(pageApiRequests).toBe(1); await expect(page.getByText('Loading...')).toHaveCount(0); }); + +for (const pendingAsset of [false, true]) { + test(`Workspace switch discards ${pendingAsset ? 'late' : 'loaded'} plugin assets`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { + authenticated: true, + workspaces: [ + makeWorkspaceEntry('workspace-alpha', 'Alpha Workspace'), + makeWorkspaceEntry('workspace-beta', 'Beta Workspace'), + ], + }); + await page.route('**/api/v1/plugins', async (route) => { + const scope = route.request().headers()['x-workspace-id']; + expect(['workspace-alpha', 'workspace-beta']).toContain(scope); + const label = scope === 'workspace-alpha' ? 'Alpha page' : 'Beta page'; + await route.fulfill({ + json: { + code: 0, + data: { + plugins: [ + { + install_source: 'marketplace', + install_info: {}, + debug: false, + manifest: { + manifest: { + metadata: { + author: 'qa', + name: 'scoped-page', + version: '1.0.0', + label: { en_US: label }, + }, + spec: { + pages: [ + { + id: 'overview', + path: 'overview.html', + label: { en_US: label }, + }, + ], + }, + }, + }, + }, + ], + }, + }, + }); + }); + const assetHeaders: Record[] = []; + let releaseAsset = () => {}; + const assetGate = new Promise((resolve) => { + releaseAsset = resolve; + }); + let assetSettled = () => {}; + const assetFinished = new Promise((resolve) => { + assetSettled = resolve; + }); + await page.route( + '**/api/v1/plugins/qa/scoped-page/authenticated-assets/overview.html', + async (route) => { + const headers = route.request().headers(); + assetHeaders.push(headers); + expect(['workspace-alpha', 'workspace-beta']).toContain( + headers['x-workspace-id'], + ); + if (pendingAsset && headers['x-workspace-id'] === 'workspace-alpha') { + await assetGate; + } + const label = + headers['x-workspace-id'] === 'workspace-alpha' ? 'Alpha' : 'Beta'; + await route.fulfill({ + contentType: 'text/html', + body: `

${label} private page

`, + }); + if (headers['x-workspace-id'] === 'workspace-alpha') assetSettled(); + }, + ); + + try { + await page.goto('/home/plugin-pages?id=qa%2Fscoped-page%2Foverview'); + await page.getByRole('button', { name: /Alpha Workspace/ }).click(); + await expect( + page.getByRole('button', { name: 'Alpha page' }), + ).toBeVisible(); + await expect.poll(() => assetHeaders.length).toBeGreaterThan(0); + let previousAssetUrl: string | null = null; + if (!pendingAsset) { + await expect( + page.frameLocator('iframe').getByRole('heading', { + name: 'Alpha private page', + }), + ).toBeVisible(); + previousAssetUrl = await page.locator('iframe').getAttribute('src'); + } + expect(assetHeaders[0]['x-workspace-id']).toBe('workspace-alpha'); + expect(assetHeaders[0].authorization).toBe('Bearer playwright-token'); + + await page.getByRole('button', { name: 'Switch Workspace' }).click(); + await page.getByRole('menuitem', { name: /Beta Workspace/ }).click(); + const switcher = page.getByRole('button', { name: 'Switch Workspace' }); + await expect(page).toHaveURL('/home/monitoring'); + await expect(switcher).toContainText('Beta Workspace'); + await expect( + page.getByRole('button', { name: 'Beta page' }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Alpha page' }), + ).toHaveCount(0); + await expect(page.locator('iframe')).toHaveCount(0); + if (previousAssetUrl) { + expect( + await page.evaluate(async (url) => { + try { + await fetch(url); + return true; + } catch { + return false; + } + }, previousAssetUrl), + ).toBe(false); + } + + const assetsBeforeBeta = assetHeaders.length; + await page.getByRole('button', { name: 'Beta page' }).click(); + await expect + .poll(() => assetHeaders.length) + .toBeGreaterThan(assetsBeforeBeta); + expect(assetHeaders.at(-1)?.['x-workspace-id']).toBe('workspace-beta'); + expect(assetHeaders.at(-1)?.authorization).toBe( + 'Bearer playwright-token', + ); + const pluginFrame = page.frameLocator('iframe'); + await expect( + pluginFrame.getByRole('heading', { name: 'Beta private page' }), + ).toBeVisible(); + releaseAsset(); + await assetFinished; + await expect( + pluginFrame.getByRole('heading', { name: 'Beta private page' }), + ).toBeVisible(); + await expect( + pluginFrame.getByRole('heading', { name: 'Alpha private page' }), + ).toHaveCount(0); + } finally { + releaseAsset(); + } + }); +} +test('plugin asset hook replaces and revokes URLs on a same-document Workspace change', async ({ + page, +}) => { + const first = makeWorkspaceEntry('asset-first', 'First', 'local'); + const second = makeWorkspaceEntry('asset-second', 'Second', 'local'); + await installLangBotApiMocks(page, { + authenticated: true, + workspaces: [first], + }); + const headers: Array> = []; + await page.route( + '**/api/v1/plugins/test/asset/authenticated-assets/page.html', + async (route) => { + headers.push(route.request().headers()); + await route.fulfill({ + contentType: 'text/html', + body: `

${headers.at(-1)?.['x-workspace-id']}

`, + }); + }, + ); + await page.goto('/home/bots'); + await expect + .poll(() => + page.evaluate(async () => { + const path = '/src/app/infra/http/currentWorkspaceStore.ts'; + return (await import(path)).getCurrentWorkspaceSnapshot()?.workspace + .uuid; + }), + ) + .toBe('asset-first'); + await page.evaluate(async () => { + const resources = performance + .getEntriesByType('resource') + .map((entry) => entry.name); + const reactPath = resources.find((url) => + new URL(url).pathname.endsWith('/react.js'), + )!; + const domPath = resources.find((url) => + new URL(url).pathname.endsWith('/react-dom_client.js'), + )!; + const React = (await import(reactPath)).default; + const domModule = await import(domPath); + const { createRoot } = domModule.default || domModule; + const hookPath = '/src/hooks/useAuthenticatedPluginResource.ts'; + const { useAuthenticatedPluginAsset } = await import(hookPath); + const host = document.createElement('main'); + host.id = 'asset-hook'; + document.getElementById('root')!.style.display = 'none'; + document.body.appendChild(host); + const revoke = URL.revokeObjectURL.bind(URL); + URL.revokeObjectURL = (url) => { + host.dataset.revoked = JSON.stringify([ + ...JSON.parse(host.dataset.revoked || '[]'), + url, + ]); + revoke(url); + }; + function Resource() { + const resource = useAuthenticatedPluginAsset( + 'test', + 'asset', + 'page.html', + ); + return React.createElement( + 'output', + { 'data-testid': 'asset-url' }, + resource.url, + ); + } + function Harness() { + const [mounted, setMounted] = React.useState(true); + return React.createElement( + React.Fragment, + null, + mounted && React.createElement(Resource), + React.createElement( + 'button', + { onClick: () => setMounted(false) }, + 'Unmount resource', + ), + ); + } + createRoot(host).render(React.createElement(Harness)); + }); + const output = page.getByTestId('asset-url'); + await expect(output).toHaveText(/^blob:/); + const oldURL = await output.textContent(); + await page.evaluate(async (workspace) => { + const contextPath = '/src/app/infra/http/workspaceContext.ts'; + const storePath = '/src/app/infra/http/currentWorkspaceStore.ts'; + const context = await import(contextPath); + const store = await import(storePath); + context.setActiveWorkspaceUuid(workspace.workspace.uuid); + store.setCurrentWorkspaceSnapshot(workspace); + }, second); + await expect(output).toHaveText(/^blob:/); + await expect(output).not.toHaveText(oldURL!); + await expect + .poll(() => headers.map((item) => item['x-workspace-id'])) + .toEqual(['asset-first', 'asset-second']); + expect( + headers.every((item) => item.authorization === 'Bearer playwright-token'), + ).toBe(true); + const newURL = await output.textContent(); + await expect + .poll(() => page.locator('#asset-hook').getAttribute('data-revoked')) + .toBe(JSON.stringify([oldURL])); + await page.getByRole('button', { name: 'Unmount resource' }).click(); + await expect(output).toHaveCount(0); + await expect + .poll(() => page.locator('#asset-hook').getAttribute('data-revoked')) + .toBe(JSON.stringify([oldURL, newURL])); +}); diff --git a/web/tests/e2e/plugin-upgrade-recovery.spec.ts b/web/tests/e2e/plugin-upgrade-recovery.spec.ts new file mode 100644 index 000000000..e508de22e --- /dev/null +++ b/web/tests/e2e/plugin-upgrade-recovery.spec.ts @@ -0,0 +1,142 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +for (const entry of ['list', 'sidebar'] as const) { + test(`plugin upgrade from ${entry} recovers its operation and progress after reload`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + let upgrades = 0, + phase: 'checking' | 'validating' | 'launching' | 'done' | 'failed' = + 'checking'; + const task = () => ({ + id: 80, + name: 'plugin-upgrade-qa/upgradable', + label: 'qa/upgradable', + runtime: { + done: phase === 'done' || phase === 'failed', + exception: phase === 'failed' ? 'Signature rejected fixture' : null, + }, + task_context: { + current_action: { + checking: 'Checking update', + validating: 'Validating package', + launching: 'Waiting for plugin to become ready', + done: 'Plugin updated', + failed: 'Validating package', + }[phase], + metadata: { plugin_name: 'qa/upgradable', operation: 'upgrade' }, + }, + }); + const plugin = { + install_source: 'marketplace', + install_info: {}, + debug: false, + manifest: { + manifest: { + metadata: { + author: 'qa', + name: 'upgradable', + label: { en_US: 'Upgrade Fixture' }, + version: '1.0.0', + description: { en_US: 'Upgrade fixture plugin' }, + }, + spec: {}, + }, + }, + }; + await page.route('**/api/v1/plugins', (route) => + route.fulfill({ json: { code: 0, data: { plugins: [plugin] } } }), + ); + await page.route('**/api/v1/extensions', (route) => + route.fulfill({ + json: { code: 0, data: { extensions: [{ type: 'plugin', plugin }] } }, + }), + ); + await page.route( + '**/api/v1/plugins/qa/upgradable/upgrade', + async (route) => { + upgrades++; + expect(route.request().method()).toBe('POST'); + await route.fulfill({ json: { code: 0, data: { task_id: 80 } } }); + }, + ); + await page.route('**/api/v1/system/tasks?**', (route) => + route.fulfill({ + json: { code: 0, data: { tasks: upgrades ? [task()] : [] } }, + }), + ); + await page.route('**/api/v1/system/tasks/80', (route) => + route.fulfill({ json: { code: 0, data: task() } }), + ); + await page.goto('/home/extensions'); + if (entry === 'list') { + const card = page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('Upgrade Fixture', { exact: true }) }); + await card.getByRole('button').last().click(); + } else { + const item = page + .locator('[data-slot="sidebar-menu-sub-item"]') + .filter({ has: page.getByRole('link', { name: /Upgrade Fixture/ }) }); + await item.hover(); + await item.getByRole('button').click(); + } + await page + .getByRole('menuitem', { name: 'Update Plugin', exact: true }) + .click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Confirm Update', exact: true }) + .click(); + let dialog = page.getByRole('dialog'); + await expect( + dialog.getByRole('heading', { + name: 'Updating qa/upgradable', + exact: true, + }), + ).toBeVisible(); + await expect.poll(() => upgrades).toBe(1); + phase = 'validating'; + await expect(dialog.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '45', + ); + await page.reload(); + await page.getByRole('button', { name: /Plugin Tasks/ }).click(); + await page.getByText('qa/upgradable', { exact: true }).click(); + dialog = page.getByRole('dialog'); + await expect( + dialog.getByRole('heading', { + name: 'Updating qa/upgradable', + exact: true, + }), + ).toBeVisible(); + await expect(dialog.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '45', + ); + phase = 'launching'; + await expect + .poll(async () => + Number( + await dialog.getByRole('progressbar').getAttribute('aria-valuenow'), + ), + ) + .toBeGreaterThanOrEqual(88); + phase = entry === 'list' ? 'done' : 'failed'; + if (phase === 'done') { + await expect( + dialog.getByText('Plugin updated successfully', { exact: true }), + ).toBeVisible(); + await expect(dialog.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '100', + ); + } else + await expect( + dialog.getByText('Signature rejected fixture', { exact: true }), + ).toBeVisible(); + expect(upgrades).toBe(1); + }); +} diff --git a/web/tests/e2e/processor-detail-workbench.spec.ts b/web/tests/e2e/processor-detail-workbench.spec.ts index 6bdb5b45e..bb053e0f9 100644 --- a/web/tests/e2e/processor-detail-workbench.spec.ts +++ b/web/tests/e2e/processor-detail-workbench.spec.ts @@ -77,6 +77,16 @@ test.describe('processor detail workbench', () => { expect(debugBox!.y).toBeGreaterThanOrEqual(0); const flow = configPanel.getByRole('tablist'); + await expect(flow.getByRole('tab')).toHaveCount(2); + const tabs = await flow.getByRole('tab').all(); + const positions = await Promise.all(tabs.map((tab) => tab.boundingBox())); + expect(positions.every((position) => position !== null)).toBe(true); + for (let index = 1; index < positions.length; index++) { + expect( + Math.abs(positions[index]!.y - positions[0]!.y), + ).toBeLessThanOrEqual(1); + expect(positions[index]!.x).toBeGreaterThan(positions[index - 1]!.x); + } await expect(flow.getByRole('tab').nth(0)).toContainText('Runner'); await expect(flow.getByRole('tab').nth(1)).toContainText('Events & tools'); await expect(flow.getByRole('tab')).toHaveCount(2); @@ -490,6 +500,16 @@ test.describe('processor detail workbench', () => { expect(debugBox!.y).toBeGreaterThanOrEqual(0); const flow = configPanel.getByRole('tablist'); + await expect(flow.getByRole('tab')).toHaveCount(3); + const tabs = await flow.getByRole('tab').all(); + const positions = await Promise.all(tabs.map((tab) => tab.boundingBox())); + expect(positions.every((position) => position !== null)).toBe(true); + for (let index = 1; index < positions.length; index++) { + expect( + Math.abs(positions[index]!.y - positions[0]!.y), + ).toBeLessThanOrEqual(1); + expect(positions[index]!.x).toBeGreaterThan(positions[index - 1]!.x); + } await expect(flow.getByRole('tab').nth(0)).toContainText('Trigger'); await expect(flow.getByRole('tab').nth(1)).toContainText('AI'); await expect(flow.getByRole('tab').nth(2)).toContainText('Output'); diff --git a/web/tests/e2e/qr-code-workspace.spec.ts b/web/tests/e2e/qr-code-workspace.spec.ts new file mode 100644 index 000000000..063c5f466 --- /dev/null +++ b/web/tests/e2e/qr-code-workspace.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +for (const cleanup of ['close', 'expiry'] as const) { + test(`QR start, polling and ${cleanup} cleanup retain the session Workspace`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + const calls: Array<{ + method: string; + origin: string; + path: string; + workspace?: string; + authorization?: string; + }> = []; + const endpoint = '/api/v1/platform/adapters/weixin/login'; + await page.route(`**${endpoint}**`, async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + calls.push({ + method: request.method(), + origin: new URL(request.url()).origin, + path, + workspace: request.headers()['x-workspace-id'], + authorization: request.headers().authorization, + }); + const data = + request.method() === 'POST' + ? { + session_id: 'qr-session', + qr_url: 'https://scan.example/qr', + expire_at: Date.now() / 1000 + 8, + } + : { status: 'waiting' }; + await route.fulfill({ json: { code: 0, data } }); + }); + await page.goto('/home/bots'); + await expect( + page.getByRole('button', { name: 'Models', exact: true }), + ).toBeVisible(); + const modules = await page.evaluate(() => { + const resources = performance + .getEntriesByType('resource') + .map((entry) => entry.name); + return { + reactPath: resources.find((url) => + new URL(url).pathname.endsWith('/react.js'), + )!, + domPath: resources.find((url) => + new URL(url).pathname.endsWith('/react-dom_client.js'), + )!, + }; + }); + await page.clock.install(); + await page.evaluate(async ({ reactPath, domPath }) => { + const reactModule = await import(reactPath); + const React = reactModule.default || reactModule; + const domModule = await import(domPath); + const { createRoot } = domModule.default || domModule; + const componentPath = + '/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx'; + const { default: Dialog } = await import(componentPath); + const contextPath = '/src/app/infra/http/workspaceContext.ts'; + (await import(contextPath)).setActiveWorkspaceUuid('qr-original'); + const host = document.createElement('div'); + document.body.appendChild(host); + function Harness() { + const [open, setOpen] = React.useState(true); + return React.createElement(Dialog, { + open, + onOpenChange: setOpen, + platform: 'weixin', + onSuccess: () => {}, + }); + } + createRoot(host).render(React.createElement(Harness)); + }, modules); + await expect(page.getByRole('dialog').locator('img')).toBeVisible(); + await page.evaluate(async () => { + const path = '/src/app/infra/http/workspaceContext.ts'; + (await import(path)).setActiveWorkspaceUuid('qr-other'); + }); + await page.clock.runFor(3100); + await expect + .poll(() => calls.some((call) => call.method === 'GET')) + .toBe(true); + if (cleanup === 'close') await page.keyboard.press('Escape'); + else await page.clock.runFor(6000); + await expect + .poll(() => calls.filter((call) => call.method === 'DELETE').length) + .toBe(1); + expect(calls).toEqual([ + { + method: 'POST', + origin: new URL(page.url()).origin, + path: endpoint, + workspace: 'qr-original', + authorization: 'Bearer playwright-token', + }, + ...calls + .filter((call) => call.method === 'GET') + .map(() => ({ + method: 'GET', + origin: new URL(page.url()).origin, + path: `${endpoint}/status/qr-session`, + workspace: 'qr-original', + authorization: 'Bearer playwright-token', + })), + { + method: 'DELETE', + origin: new URL(page.url()).origin, + path: `${endpoint}/qr-session`, + workspace: 'qr-original', + authorization: 'Bearer playwright-token', + }, + ]); + }); +} diff --git a/web/tests/e2e/quota-create-actions.spec.ts b/web/tests/e2e/quota-create-actions.spec.ts index 2d275c6e2..8c1c64251 100644 --- a/web/tests/e2e/quota-create-actions.spec.ts +++ b/web/tests/e2e/quota-create-actions.spec.ts @@ -148,3 +148,192 @@ test('quota-reached create actions are disabled and explain the current limit', ), ).toBeVisible(); }); + +for (const kind of ['plugin', 'skill'] as const) { + test(`GitHub ${kind} confirmation rejects a failed quota check and can retry`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/system/info', (route) => + fulfill(route, { + debug: false, + version: 'quota-e2e', + edition: 'community', + cloud_service_url: 'https://space.langbot.app', + enable_marketplace: true, + allow_modify_login_info: true, + disable_models_service: false, + limitation: { max_extensions: 3 }, + outbound_ips: [], + wizard_status: 'completed', + wizard_progress: null, + }), + ); + + let checkingQuota = false; + let quotaChecks = 0; + let releaseQuotaCheck!: () => void; + const quotaCheckGate = new Promise((resolve) => { + releaseQuotaCheck = resolve; + }); + await page.route('**/api/v1/plugins', async (route) => { + if (checkingQuota) { + quotaChecks += 1; + await quotaCheckGate; + checkingQuota = false; + await route.fulfill({ + status: 503, + json: { code: 503, message: 'Quota service unavailable' }, + }); + return; + } + await fulfill(route, { plugins: [] }); + }); + + const skillUrl = + 'https://github.com/quota-fixture/extension/blob/main/SKILL.md'; + await page.route('**/api/v1/plugins/github/releases', (route) => + fulfill(route, { + owner: 'quota-fixture', + repo: 'extension', + releases: [ + { + id: 7, + tag_name: 'v1.0.0', + name: 'Quota Test Release', + published_at: '2026-01-01T00:00:00Z', + prerelease: false, + draft: false, + }, + ], + }), + ); + await page.route('**/api/v1/plugins/github/release-assets', (route) => + fulfill(route, { + assets: [ + { + id: 42, + name: 'quota-test.lbpkg', + size: 1024, + download_url: 'https://example.com/quota-test.lbpkg', + content_type: 'application/octet-stream', + }, + ], + }), + ); + await page.route('**/api/v1/skills/install/github/preview', (route) => + fulfill(route, { + skills: [ + { + name: 'quota-test-skill', + display_name: 'Quota Test Skill', + description: 'Skill used to verify quota confirmation', + instructions: '# Quota Test Skill', + package_root: '.', + }, + ], + }), + ); + + const installRequests: { path: string; method: string; body: unknown }[] = + []; + await page.route( + /\/api\/v1\/(plugins|skills)\/install\/github$/, + (route) => { + const request = route.request(); + installRequests.push({ + path: new URL(request.url()).pathname, + method: request.method(), + body: request.postDataJSON(), + }); + return fulfill( + route, + kind === 'plugin' ? { task_id: 'quota-test-task' } : { skills: [] }, + ); + }, + ); + + await page.goto('/home/add-extension'); + await page.getByRole('button', { name: 'Manual Add', exact: true }).click(); + await page.getByRole('button', { name: 'Install from GitHub' }).click(); + await page + .getByPlaceholder('GitHub repository, Release, or SKILL.md link') + .fill( + kind === 'plugin' + ? 'https://github.com/quota-fixture/extension' + : skillUrl, + ); + const confirm = page.getByRole('button', { name: 'Confirm', exact: true }); + await confirm.click(); + if (kind === 'plugin') { + await page.getByText('Quota Test Release', { exact: true }).click(); + await page.getByText('quota-test.lbpkg', { exact: true }).click(); + } else { + await expect( + page.getByText('Quota Test Skill', { exact: true }), + ).toBeVisible(); + } + + checkingQuota = true; + await confirm.click(); + try { + await expect.poll(() => quotaChecks).toBe(1); + await expect( + page.getByText(`Installing ${kind}...`, { exact: true }), + ).toBeVisible(); + await expect(confirm).toHaveCount(0); + await page.keyboard.press('Enter'); + expect(quotaChecks).toBe(1); + expect(installRequests).toEqual([]); + } finally { + releaseQuotaCheck(); + } + + await expect( + page.getByText( + 'Unable to verify the current workspace quota. Please try again.', + ), + ).toBeVisible(); + expect(installRequests).toEqual([]); + await expect(confirm).toBeEnabled(); + + await confirm.click(); + await expect.poll(() => installRequests.length).toBe(1); + await expect( + page.getByRole('button', { + name: 'Manual Add', + exact: true, + includeHidden: true, + }), + ).toHaveAttribute('aria-expanded', 'false'); + await expect( + page.getByPlaceholder('GitHub repository, Release, or SKILL.md link'), + ).toBeHidden(); + expect(installRequests).toEqual([ + { + path: `/api/v1/${kind === 'plugin' ? 'plugins' : 'skills'}/install/github`, + method: 'POST', + body: + kind === 'plugin' + ? { + asset_id: 42, + release_id: 7, + owner: 'quota-fixture', + repo: 'extension', + release_tag: 'v1.0.0', + } + : { + asset_url: skillUrl, + owner: 'quota-fixture', + repo: 'extension', + release_tag: 'main', + }, + }, + ]); + if (kind === 'skill') { + await expect( + page.getByText('Skill installed successfully', { exact: true }), + ).toBeVisible(); + } + }); +} diff --git a/web/tests/e2e/space-launch.spec.ts b/web/tests/e2e/space-launch.spec.ts new file mode 100644 index 000000000..eae42799f --- /dev/null +++ b/web/tests/e2e/space-launch.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +for (const fragment of [true, false]) { + test(`direct launch assertion is fragment-only and cleared before exchange, fragment=${fragment}`, async ({ + page, + }) => { + await installLangBotApiMocks(page); + await page.addInitScript(() => { + const replace = history.replaceState.bind(history); + history.replaceState = (...args) => { + sessionStorage.setItem('last-replaced-url', String(args[2])); + replace(...args); + }; + }); + let body: unknown; + let fragmentAtExchange: string | null = null; + await page.route('**/api/v1/user/space/callback', async (route) => { + body = route.request().postDataJSON(); + fragmentAtExchange = await page.evaluate(() => location.hash); + await route.fulfill({ + status: 403, + json: { code: 'launch_denied', msg: 'Denied for test' }, + }); + }); + const query = '/auth/space/callback?code=launch-code&state=launch-state'; + await page.goto( + fragment + ? `${query}#workspace_uuid=target-workspace&launch_assertion=fragment-secret` + : `${query}&workspace_uuid=target-workspace&launch_assertion=query-must-not-be-used`, + ); + await expect( + page.getByRole('button', { name: 'Back to Login', exact: true }), + ).toBeVisible(); + if (fragment) { + expect(body).toEqual({ + code: 'launch-code', + state: 'launch-state', + redirect_uri: new URL('/auth/space/callback', page.url()).href, + workspace_uuid: 'target-workspace', + launch_assertion: 'fragment-secret', + }); + expect(fragmentAtExchange).toBe(''); + } else { + expect(body).toBeUndefined(); + expect(fragmentAtExchange).toBeNull(); + } + if (fragment) + expect( + await page.evaluate(() => sessionStorage.getItem('last-replaced-url')), + ).toBe(query); + }); +} diff --git a/web/tests/e2e/wizard-platform-regressions.spec.ts b/web/tests/e2e/wizard-platform-regressions.spec.ts index 7859e6acd..d74e2863b 100644 --- a/web/tests/e2e/wizard-platform-regressions.spec.ts +++ b/web/tests/e2e/wizard-platform-regressions.spec.ts @@ -364,6 +364,39 @@ test.describe('wizard and QR platform regressions', () => { .toBe(true); }); + test('a normal Page Bot embed has no wizard notice and stays closed until opened', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.goto('/login'); + const source = fs + .readFileSync( + path.resolve(process.cwd(), '../src/langbot/templates/embed/widget.js'), + 'utf8', + ) + .replaceAll('__LANGBOT_LOCALE__', 'en_US') + .replaceAll('__LANGBOT_BOT_UUID__', 'bot-1') + .replaceAll('__LANGBOT_BASE_URL__', 'https://embed.test') + .replaceAll('__LANGBOT_TURNSTILE_SITE_KEY__', '') + .replaceAll('__LANGBOT_BUBBLE_ICON__', 'chat'); + await page.addScriptTag({ content: source }); + const root = page.locator('#langbot-widget-root'); + await expect(root).toBeAttached(); + expect( + await root.evaluate( + (element) => + element.shadowRoot?.querySelectorAll('.lb-test-notice').length, + ), + ).toBe(0); + expect( + await root.evaluate((element) => + element.shadowRoot + ?.querySelector('.lb-panel') + ?.classList.contains('lb-visible'), + ), + ).toBe(false); + }); + test('binds HTTP Bot before its signed inbound verification request', async ({ page, }) => { @@ -446,6 +479,16 @@ test.describe('wizard and QR platform regressions', () => { async (route) => { inboundTestCount += 1; expect(route.request().method()).toBe('POST'); + expect(route.request().postDataJSON()).toEqual({ + message: 'Explicit HTTP verification', + }); + expect(route.request().headers().authorization).toBe( + 'Bearer playwright-token', + ); + expect(route.request().headers()['x-workspace-id']).toBe( + 'workspace-playwright', + ); + expect(boundPipelineUuid).toBe('pipeline-1'); await route.fulfill({ status: 200, contentType: 'application/json', @@ -467,152 +510,186 @@ test.describe('wizard and QR platform regressions', () => { await expect.poll(() => boundPipelineUuid).toBe('pipeline-1'); await expect.poll(() => savedInboundSecret).toMatch(/^[a-f0-9]{64}$/); + await page + .getByRole('button', { name: 'Send Test Message', exact: true }) + .locator('..') + .locator('input') + .fill(' Explicit HTTP verification '); await page.getByRole('button', { name: 'Send Test' }).click(); await expect.poll(() => inboundTestCount).toBe(1); }); - test('creates only a message pipeline after message verification and valid Runner configuration', async ({ - page, - }) => { - await installLangBotApiMocks(page, { - authenticated: true, - withAdapterEvents: true, - }); - const processorRequests: string[] = []; - page.on('request', (request) => { - const path = new URL(request.url()).pathname; - if ( - request.method() === 'POST' && - ['/api/v1/pipelines', '/api/v1/agents'].includes(path) - ) { - processorRequests.push(path); - } - }); - let messageReceived = false; - await page.route('**/api/v1/platform/bots/*/logs', async (route) => { - await route.fulfill({ - contentType: 'application/json', - body: ok({ - logs: messageReceived - ? [ - { - seq_id: 1, - timestamp: Date.now() / 1000, - level: 'info', - text: 'Received message', - images: [], - message_session_id: 'person_123', - }, - ] - : [], - total_count: messageReceived ? 1 : 0, - }), + for (const accountType of ['local', 'space'] as const) { + test(`creates only a message pipeline after message verification and valid Runner configuration for ${accountType}`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { + authenticated: true, + withAdapterEvents: true, }); - }); - await page.route('**/api/v1/pipelines/_/metadata', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: ok({ - configs: [ - { - name: 'ai', - label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' }, - stages: [ - { - name: 'plugin:langbot-team/LocalAgent/default', - config: [ - { - name: 'model', - type: 'model-fallback-selector', - required: true, - default: { primary: 'llm-valid', fallbacks: [] }, - }, - ], - }, - { - name: 'runner', - label: { en_US: 'Runtime', zh_Hans: '运行方式' }, - config: [ - { - name: 'id', - label: { en_US: 'Runner', zh_Hans: '运行器' }, - type: 'select', - required: true, - default: 'external-runner', - options: [ - { - name: 'external-runner', - label: { - en_US: 'External Runner', - zh_Hans: '外部运行器', - }, - }, - ], - }, - ], - }, - { - name: 'external-runner', - label: { - en_US: 'External Runner', - zh_Hans: '外部运行器', - }, - config: [ - { - id: 'api-key', - name: 'api-key', - label: { en_US: 'API Key', zh_Hans: 'API 密钥' }, - type: 'secret', - required: true, - default: 'your-api-key', - }, - ], - }, - ], + await page.route('**/api/v1/user/info', (route) => + route.fulfill({ + json: { + code: 0, + data: { + account_uuid: + accountType === 'space' ? 'account-playwright' : null, + user: 'admin@example.com', + account_type: accountType, + has_password: true, }, - ], + }, }), + ); + const processorRequests: string[] = []; + page.on('request', (request) => { + const path = new URL(request.url()).pathname; + if ( + request.method() === 'POST' && + ['/api/v1/pipelines', '/api/v1/agents'].includes(path) + ) { + processorRequests.push(path); + } }); + let messageReceived = false; + await page.route('**/api/v1/platform/bots/*/logs', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: ok({ + logs: messageReceived + ? [ + { + seq_id: 1, + timestamp: Date.now() / 1000, + level: 'info', + text: 'Received message', + images: [], + message_session_id: 'person_123', + }, + ] + : [], + total_count: messageReceived ? 1 : 0, + }), + }); + }); + await page.route('**/api/v1/pipelines/_/metadata', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: ok({ + configs: [ + { + name: 'ai', + label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' }, + stages: [ + { + name: 'plugin:langbot-team/LocalAgent/default', + config: [ + { + name: 'model', + type: 'model-fallback-selector', + required: true, + default: { primary: 'llm-valid', fallbacks: [] }, + }, + ], + }, + { + name: 'runner', + label: { en_US: 'Runtime', zh_Hans: '运行方式' }, + config: [ + { + name: 'id', + label: { en_US: 'Runner', zh_Hans: '运行器' }, + type: 'select', + required: true, + default: 'external-runner', + options: [ + { + name: 'external-runner', + label: { + en_US: 'External Runner', + zh_Hans: '外部运行器', + }, + }, + ], + }, + ], + }, + { + name: 'external-runner', + label: { + en_US: 'External Runner', + zh_Hans: '外部运行器', + }, + config: [ + { + id: 'api-key', + name: 'api-key', + label: { en_US: 'API Key', zh_Hans: 'API 密钥' }, + type: 'secret', + required: true, + default: 'your-api-key', + }, + ], + }, + ], + }, + ], + }), + }); + }); + + await page.goto('/wizard'); + await page.getByText('Playwright Adapter', { exact: true }).click(); + await page.getByRole('button', { name: 'Confirm, Create Bot' }).click(); + await page.getByRole('button', { name: 'Save & Enable Bot' }).click(); + await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + messageReceived = true; + await expect( + page.getByRole('button', { name: 'Next', exact: true }), + ).toBeEnabled(); + const localWarning = page.getByText( + 'The bot-side connection is configured correctly and received an IM message. Because you are not signed in with a LangBot Account, model calls may fail; continue to the next step to add your own model.', + { exact: true }, + ); + if (accountType === 'local') await expect(localWarning).toBeVisible(); + else await expect(localWarning).toHaveCount(0); + await page.getByRole('button', { name: 'Next' }).click(); + await expect( + page.getByText('Use the default setup', { exact: true }), + ).toBeVisible(); + await page + .getByText('Connect an External Agent', { exact: true }) + .click(); + await page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('External Runner', { exact: true }) }) + .getByRole('button', { name: 'Use This Runner' }) + .click(); + + const deployButton = page.getByRole('button', { + name: 'Create & Deploy', + }); + await expect(deployButton).toBeDisabled(); + await page.getByRole('textbox').fill('app-real-api-key'); + await expect(deployButton).toBeEnabled(); + const bindingRequest = page.waitForRequest( + (request) => + request.method() === 'PUT' && + new URL(request.url()).pathname === '/api/v1/pipelines/pipeline-1', + ); + await deployButton.click(); + const body = (await bindingRequest).postDataJSON(); + expect(body.config.ai.runner.id).toBe('external-runner'); + expect(body.config.ai.runner_config['external-runner']['api-key']).toBe( + 'app-real-api-key', + ); + await expect( + page.getByRole('button', { name: 'Back to Workbench' }), + ).toBeVisible(); + expect(processorRequests).toEqual(['/api/v1/pipelines']); }); - - await page.goto('/wizard'); - await page.getByText('Playwright Adapter', { exact: true }).click(); - await page.getByRole('button', { name: 'Confirm, Create Bot' }).click(); - await page.getByRole('button', { name: 'Save & Enable Bot' }).click(); - await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); - messageReceived = true; - await page.getByRole('button', { name: 'Next' }).click(); - await expect( - page.getByText('Use the default setup', { exact: true }), - ).toBeVisible(); - await page.getByText('Connect an External Agent', { exact: true }).click(); - await page - .locator('[data-slot="card"]') - .filter({ has: page.getByText('External Runner', { exact: true }) }) - .getByRole('button', { name: 'Use This Runner' }) - .click(); - - const deployButton = page.getByRole('button', { name: 'Create & Deploy' }); - await expect(deployButton).toBeDisabled(); - await page.getByRole('textbox').fill('app-real-api-key'); - await expect(deployButton).toBeEnabled(); - const bindingRequest = page.waitForRequest( - (request) => - request.method() === 'PUT' && - new URL(request.url()).pathname === '/api/v1/pipelines/pipeline-1', - ); - await deployButton.click(); - const body = (await bindingRequest).postDataJSON(); - expect(body.config.ai.runner.id).toBe('external-runner'); - expect(body.config.ai.runner_config['external-runner']['api-key']).toBe( - 'app-real-api-key', - ); - await expect( - page.getByRole('button', { name: 'Back to Workbench' }), - ).toBeVisible(); - expect(processorRequests).toEqual(['/api/v1/pipelines']); - }); + } for (const qrPlatform of qrPlatforms) { test(`${qrPlatform.label} requests and displays its QR code`, async ({ diff --git a/web/tests/e2e/workspace-assistant.spec.ts b/web/tests/e2e/workspace-assistant.spec.ts index 1f0a4edcc..102d48739 100644 --- a/web/tests/e2e/workspace-assistant.spec.ts +++ b/web/tests/e2e/workspace-assistant.spec.ts @@ -570,16 +570,34 @@ test('setup login starts the existing LangBot Account authorization flow', async await page.route('**/api/v1/provider/models/llm', (route) => route.fulfill({ json: { code: 0, data: { models: [] } } }), ); + const authorizationUrl = + 'https://space.langbot.app/authorize?state=assistant-bind-state'; + await page.route('**/api/v1/user/space/bind-authorize-url?**', (route) => + route.fulfill({ + json: { code: 0, data: { authorize_url: authorizationUrl } }, + }), + ); await page.goto('/home/bots'); + const origin = new URL(page.url()).origin; const request = page.waitForRequest( (request) => - request.url().includes('/space/') && request.url().includes('authorize'), + new URL(request.url()).pathname === + '/api/v1/user/space/bind-authorize-url', ); await page .getByRole('dialog', { name: 'Workspace assistant' }) .getByRole('button', { name: 'Sign in to LangBot Account' }) .click(); - expect((await request).url()).toContain('authorize'); + const authorization = await request; + expect(authorization.method()).toBe('GET'); + expect(new URL(authorization.url()).searchParams.get('redirect_uri')).toBe( + `${origin}/auth/space/callback?mode=bind`, + ); + expect(authorization.headers()['authorization']).toBe( + 'Bearer playwright-token', + ); + expect(authorization.headers()['x-workspace-id']).toBeUndefined(); + await expect(page).toHaveURL(authorizationUrl); }); test('switches conversations, restores background progress after reload and stops only the selected task', async ({ diff --git a/web/tests/e2e/workspace-layout.spec.ts b/web/tests/e2e/workspace-layout.spec.ts new file mode 100644 index 000000000..dae717afd --- /dev/null +++ b/web/tests/e2e/workspace-layout.spec.ts @@ -0,0 +1,213 @@ +import { expect, test } from '@playwright/test'; +import { + installLangBotApiMocks, + makeWorkspaceEntry, +} from './fixtures/langbot-api'; + +const cloudSystemInfo = { + edition: 'cloud', + deployment_mode: 'cloud', + cloud_service_url: 'https://space.langbot.app', + wizard_status: 'completed', +}; + +for (const scenario of [ + 'singleton-local', + 'singleton-cloud', + 'multiple', +] as const) { + test(`Workspace switcher visibility and placement for ${scenario}`, async ({ + page, + }) => { + const first = makeWorkspaceEntry( + 'layout-workspace', + 'A Workspace with a deliberately very long name to exercise truncation', + scenario === 'singleton-local' ? 'local' : 'cloud_projection', + ); + const workspaces = + scenario === 'multiple' + ? [first, makeWorkspaceEntry('other-workspace', 'Other', 'local')] + : [first]; + await installLangBotApiMocks(page, { + authenticated: true, + workspaces, + storage: { langbot_active_workspace_uuid: first.workspace.uuid }, + }); + if (scenario !== 'singleton-local') { + await page.route('**/api/v1/system/info', (route) => + route.fulfill({ json: { code: 0, data: cloudSystemInfo } }), + ); + await page.route( + /\/api\/v1\/workspaces\/layout-workspace\/(members|invitations)$/, + (route) => + route.fulfill({ + json: { code: 0, data: { members: [], invitations: [] } }, + }), + ); + } + await page.goto('/home/bots'); + await expect( + page.getByRole('button', { name: 'Models', exact: true }), + ).toBeVisible(); + const trigger = page.getByRole('button', { + name: 'Switch Workspace', + exact: true, + }); + await expect(trigger).toHaveCount(scenario === 'singleton-local' ? 0 : 1); + if (scenario === 'singleton-local') return; + const sidebar = page.locator('[data-slot="sidebar-inner"]'); + const header = sidebar.locator('[data-slot="sidebar-header"]'); + const home = sidebar.getByText('Home', { exact: true }); + const navigation = sidebar.getByRole('button', { + name: 'Models', + exact: true, + }); + const [headerBox, triggerBox, homeBox, navBox] = await Promise.all([ + header.boundingBox(), + trigger.boundingBox(), + home.boundingBox(), + navigation.boundingBox(), + ]); + expect(triggerBox!.y).toBeGreaterThanOrEqual( + headerBox!.y + headerBox!.height, + ); + expect(triggerBox!.y + triggerBox!.height).toBeLessThanOrEqual(homeBox!.y); + expect(Math.abs(triggerBox!.x - navBox!.x)).toBeLessThanOrEqual(1); + expect( + Math.abs(triggerBox!.x + triggerBox!.width - navBox!.x - navBox!.width), + ).toBeLessThanOrEqual(1); + await trigger.click(); + const row = page + .getByRole('menuitem') + .filter({ hasText: first.workspace.name }); + await expect(row).toBeVisible(); + await expect( + row.getByRole('button', { name: 'Workspace Settings', exact: true }), + ).toBeVisible(); + const longName = row.getByText(first.workspace.name, { exact: true }); + expect( + await longName.evaluate((element) => ({ + overflow: getComputedStyle(element).textOverflow, + whiteSpace: getComputedStyle(element).whiteSpace, + clipped: element.scrollWidth > element.clientWidth, + })), + ).toEqual({ overflow: 'ellipsis', whiteSpace: 'nowrap', clipped: true }); + await row + .getByRole('button', { name: 'Workspace Settings', exact: true }) + .click(); + await expect( + page + .getByRole('dialog') + .getByRole('heading', { name: 'Workspace', exact: true }), + ).toBeVisible(); + await expect( + page + .getByRole('dialog') + .getByRole('heading', { name: 'Members', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('button', { + name: 'Switch Workspace', + exact: true, + includeHidden: true, + }), + ).toHaveCount(1); + }); +} + +test('the Cloud Workspace row shows its plan and opens plan upgrades in settings', async ({ + page, +}) => { + const workspace = { + ...makeWorkspaceEntry( + 'plan-workspace', + 'Plan Workspace', + 'cloud_projection', + ), + plan_name: 'Pro', + }; + await installLangBotApiMocks(page, { + authenticated: true, + workspaces: [workspace], + }); + await page.route('**/api/v1/system/info', (route) => + route.fulfill({ json: { code: 0, data: cloudSystemInfo } }), + ); + await page.route('**/api/v1/workspaces/plan-workspace/members', (route) => + route.fulfill({ json: { code: 0, data: { members: [] } } }), + ); + await page.route('**/api/v1/workspaces/plan-workspace/invitations', (route) => + route.fulfill({ json: { code: 0, data: { invitations: [] } } }), + ); + let catalogs = 0; + await page.route( + 'https://space.langbot.app/api/v1/cloud/v2/plans', + async (route) => { + catalogs++; + await route.fulfill({ json: { code: 0, data: [] } }); + }, + ); + await page.goto('/home/bots'); + await page + .getByRole('button', { name: 'Switch Workspace', exact: true }) + .click(); + const row = page.getByRole('menuitem').filter({ hasText: 'Plan Workspace' }); + await expect(row.getByText('Pro', { exact: true })).toBeVisible(); + await row + .getByRole('button', { name: 'Workspace Settings', exact: true }) + .click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page + .getByRole('button', { name: 'Compare plans', exact: true }) + .click(); + await expect( + page.getByRole('heading', { name: 'Compare plans', exact: true }), + ).toBeVisible(); + await expect(page.getByRole('dialog', { includeHidden: true })).toHaveCount( + 2, + ); + await expect.poll(() => catalogs).toBeGreaterThan(0); +}); + +test('monitoring uses infrastructure Box health independently of Plugin Runtime status', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + let runtimeRequests = 0; + let scopedStatusRequests = 0; + await page.route('**/api/v1/box/runtime-status', async (route) => { + runtimeRequests++; + await route.fulfill({ + json: { + code: 0, + data: { + available: false, + enabled: true, + connector_error: 'Box infrastructure unavailable', + }, + }, + }); + }); + await page.route('**/api/v1/box/status', async (route) => { + scopedStatusRequests++; + await route.fulfill({ + json: { code: 0, data: { available: true, enabled: true } }, + }); + }); + await page.goto('/home/monitoring'); + const card = page + .locator('[data-slot="card"]') + .filter({ hasText: 'System Status' }); + await expect(card.getByText('Box Runtime', { exact: true })).toBeVisible(); + await card.getByRole('button').click(); + const dialog = page.getByRole('dialog'); + await expect( + dialog.getByText('Box infrastructure unavailable', { exact: true }), + ).toBeVisible(); + await expect(dialog.getByText('Connected', { exact: true })).toHaveCount(1); + await expect(dialog.getByText('Disconnected', { exact: true })).toHaveCount( + 1, + ); + expect(runtimeRequests).toBeGreaterThan(0); + expect(scopedStatusRequests).toBe(0); +}); diff --git a/web/tests/e2e/workspace-member-email.spec.ts b/web/tests/e2e/workspace-member-email.spec.ts index e6f952ca5..91b8fc680 100644 --- a/web/tests/e2e/workspace-member-email.spec.ts +++ b/web/tests/e2e/workspace-member-email.spec.ts @@ -73,6 +73,10 @@ test('workspace member list displays each member email without overlapping contr await expect(page.getByText('RockChinQ')).toBeVisible(); await expect(page.getByText('rock@example.com')).toBeVisible(); await expect(page.getByText('Junyan Qin')).toBeVisible(); + const ownerRow = page + .locator('[data-slot="item"]') + .filter({ hasText: 'RockChinQ' }); + await expect(ownerRow.getByText('Owner', { exact: true })).toBeVisible(); const longEmail = page.getByText( 'a.very.long.workspace.member.email.address@example-company.test', ); @@ -80,6 +84,7 @@ test('workspace member list displays each member email without overlapping contr await longEmail.scrollIntoViewIfNeeded(); const roleSelect = page.getByRole('combobox').last(); + await expect(roleSelect).toHaveText('Admin'); const [emailBox, selectBox] = await Promise.all([ longEmail.boundingBox(), roleSelect.boundingBox(), diff --git a/web/tests/e2e/workspace-quota-contract.spec.ts b/web/tests/e2e/workspace-quota-contract.spec.ts new file mode 100644 index 000000000..036513149 --- /dev/null +++ b/web/tests/e2e/workspace-quota-contract.spec.ts @@ -0,0 +1,299 @@ +import { expect, test, type Page } from '@playwright/test'; +import { + installLangBotApiMocks, + makeWorkspaceEntry, +} from './fixtures/langbot-api'; + +const unlimited = { + max_bots: -1, + max_pipelines: -1, + max_knowledge_bases: -1, + max_extensions: -1, +}; +const resourceLimits = { bots: 2, pipelines: 3, knowledge: 4, extensions: 6 }; +const fields = { + bots: 'max_bots', + pipelines: 'max_pipelines', + knowledge: 'max_knowledge_bases', + extensions: 'max_extensions', +} as const; +const labels = { + bots: 'Create Bots', + pipelines: 'Create Processors', + knowledge: 'Create Knowledge', +} as const; +const types = ['plugin', 'mcp', 'skill'] as const; +const marketplaceEntry = (label: string, type: (typeof types)[number]) => ({ + id: 1, + author: 'quota-qa', + name: label.toLowerCase().replaceAll(' ', '-'), + label: { en_US: label }, + description: { en_US: `${label} description` }, + type, + latest_version: '1.0.0', + install_count: 0, + like_count: 0, + components: {}, + tags: [], + repository: 'https://example.invalid/extension', + icon: '', + created_at: '', + updated_at: '', +}); + +async function installQuotaFixtures( + page: Page, + source: 'local' | 'cloud_projection', + limits: typeof unlimited, +) { + const workspace = { + ...makeWorkspaceEntry('workspace-playwright', 'Quota workspace', source), + limitation: + source === 'cloud_projection' + ? limits + : { + max_bots: 0, + max_pipelines: 0, + max_knowledge_bases: 0, + max_extensions: 0, + }, + }; + await installLangBotApiMocks(page, { + authenticated: true, + workspaces: [workspace], + }); + const installationPosts: string[] = []; + page.on('request', (request) => { + if ( + request.method() === 'POST' && + /\/(plugins|skills|mcp)\//.test(request.url()) + ) + installationPosts.push(request.url()); + }); + const fulfill = ( + route: Parameters[1]>[0], + data: unknown, + ) => route.fulfill({ json: { code: 0, data } }); + await page.route('**/api/v1/system/info', (route) => + fulfill(route, { + debug: false, + version: 'quota-contract', + edition: 'community', + cloud_service_url: 'https://space.langbot.app', + enable_marketplace: true, + allow_modify_login_info: true, + disable_models_service: false, + limitation: source === 'local' ? limits : unlimited, + outbound_ips: [], + wizard_status: 'completed', + wizard_progress: null, + }), + ); + await page.route('**/api/v1/platform/bots', (route) => + fulfill(route, { + bots: Array.from({ length: 2 }, (_, i) => ({ + uuid: `quota-bot-${i}`, + name: `Quota Bot ${i}`, + description: '', + adapter: 'aiocqhttp', + enable: true, + updated_at: '2026-03-08T10:00:00Z', + })), + }), + ); + await page.route('**/api/v1/agents', (route) => + fulfill(route, { + agents: Array.from({ length: 3 }, (_, i) => ({ + uuid: `quota-pipeline-${i}`, + name: `Quota Pipeline ${i}`, + description: '', + kind: 'pipeline', + updated_at: '2026-03-08T10:00:00Z', + })), + }), + ); + await page.route('**/api/v1/knowledge/bases', (route) => + fulfill(route, { + bases: Array.from({ length: 4 }, (_, i) => ({ + uuid: `quota-kb-${i}`, + name: `Quota Knowledge ${i}`, + description: '', + updated_at: '2026-03-08T10:00:00Z', + })), + }), + ); + await page.route('**/api/v1/plugins', (route) => + fulfill(route, { + plugins: [ + { + plugin_id: 'quota-qa/installed-plugin', + install_source: 'marketplace', + debug: false, + manifest: { + manifest: { + metadata: { + author: 'quota-qa', + name: 'installed-plugin', + label: { en_US: 'Installed plugin' }, + version: '1.0.0', + }, + spec: {}, + }, + }, + }, + ], + }), + ); + await page.route('**/api/v1/mcp/servers', (route) => + fulfill(route, { + servers: Array.from({ length: 2 }, (_, i) => ({ + name: `installed-mcp-${i}`, + enable: true, + runtime_info: { status: 'connected' }, + })), + }), + ); + await page.route('**/api/v1/skills', (route) => + fulfill(route, { + skills: Array.from({ length: 3 }, (_, i) => ({ + name: `installed-skill-${i}`, + display_name: `Installed skill ${i}`, + description: '', + updated_at: '2026-03-08T10:00:00Z', + })), + }), + ); + const grid = types.map((type) => marketplaceEntry(`Grid ${type}`, type)); + const recommended = types.map((type) => + marketplaceEntry(`Recommended ${type}`, type), + ); + await page.route('**/api/v1/marketplace/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/recommendation-lists')) + return fulfill(route, { + lists: recommended.map((plugin, i) => ({ + uuid: `recommendation-${i}`, + label: { en_US: `${plugin.type} picks` }, + sort_order: i, + plugins: [plugin], + })), + }); + if (path.endsWith('/extensions/search')) + return fulfill(route, { extensions: grid, total: grid.length }); + if (path.endsWith('/search') || path.endsWith('/plugins')) + return fulfill(route, { plugins: grid, total: grid.length }); + const plugin = [...grid, ...recommended].find((entry) => + decodeURIComponent(path).endsWith(`/quota-qa/${entry.name}`), + ); + if (plugin) return fulfill(route, { plugin }); + return route.fallback(); + }); + return { + installationPosts, + cards: [...grid, ...recommended].map((entry) => entry.label.en_US), + }; +} + +for (const source of ['local', 'cloud_projection'] as const) { + for (const resource of Object.keys( + resourceLimits, + ) as (keyof typeof resourceLimits)[]) { + test(`${source}: only the ${resource} resource is blocked at its own quota`, async ({ + page, + }) => { + const limits = { + ...unlimited, + [fields[resource]]: resourceLimits[resource], + }; + await installQuotaFixtures(page, source, limits); + await page.goto('/home/bots'); + for (const [key, label] of Object.entries(labels)) { + const button = page.getByRole('button', { name: label, exact: true }); + if (key !== resource) await expect(button).toBeEnabled(); + } + if (resource !== 'extensions') { + await expect( + page.getByRole('button', { + name: labels[resource as keyof typeof labels], + exact: true, + }), + ).toBeDisabled(); + } + await page + .getByRole('button', { name: 'Add Extension', exact: true }) + .click(); + const manual = page.getByRole('button', { + name: 'Manual Add', + exact: true, + }); + if (resource === 'extensions') { + await expect(manual).toBeDisabled(); + await manual.locator('..').focus(); + await expect( + page.getByText( + 'The Extensions limit (6) for this workspace has been reached. Delete one existing item before creating another.', + { exact: true }, + ), + ).toBeVisible(); + } else await expect(manual).toBeEnabled(); + if (resource !== 'extensions') { + const button = page.getByRole('button', { + name: labels[resource as keyof typeof labels], + exact: true, + }); + await button.locator('..').focus(); + await expect( + page.getByText( + `The ${labels[resource as keyof typeof labels].slice(7)} limit (${resourceLimits[resource]}) for this workspace has been reached. Delete one existing item before creating another.`, + { exact: true }, + ), + ).toBeVisible(); + } + }); + } + + test(`${source}: every grid, recommended and manual install entry respects the mixed extension quota`, async ({ + page, + }) => { + const limits = { ...unlimited, max_extensions: 6 }; + const { cards, installationPosts } = await installQuotaFixtures( + page, + source, + limits, + ); + await page.goto('/home/add-extension'); + await expect( + page.getByRole('button', { name: 'Manual Add', exact: true }), + ).toBeDisabled(); + for (const name of cards) { + const card = page.getByRole('group', { + name: `Install ${name}`, + exact: true, + }); + await expect(card).toHaveAttribute('aria-disabled', 'true'); + await card.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog')).toHaveCount(0); + } + expect(installationPosts).toEqual([]); + + // One free slot enables every entry again, including each recommended type. + limits.max_extensions = 7; + await page.reload(); + await expect( + page.getByRole('button', { name: 'Manual Add', exact: true }), + ).toBeEnabled(); + for (const name of cards) { + const card = page.getByRole('button', { + name: `Install ${name}`, + exact: true, + }); + await expect(card).toHaveAttribute('aria-disabled', 'false'); + await card.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); + } + expect(installationPosts).toEqual([]); + }); +} diff --git a/web/tests/unit/cloud-new-account-entry.test.mjs b/web/tests/unit/cloud-new-account-entry.test.mjs deleted file mode 100644 index 8a096be33..000000000 --- a/web/tests/unit/cloud-new-account-entry.test.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import test from 'node:test'; - -const source = fs.readFileSync( - new URL('../../src/app/login/page.tsx', import.meta.url), - 'utf8', -); - -test('normal Cloud login uses the standard Space OAuth callback path', () => { - assert.doesNotMatch(source, /cloudEntry/); - assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/); -}); - -test('invitation login uses the same OAuth callback before accepting the invitation', () => { - assert.doesNotMatch(source, /cloudEntry/); - assert.match(source, /const invitationToken = getPendingInvitationToken\(\)/); - assert.match(source, /acceptWorkspaceInvitation\(invitationToken\)/); -}); diff --git a/web/tests/unit/install-progress.test.mjs b/web/tests/unit/install-progress.test.mjs index 84f176ed6..1bc5bdeee 100644 --- a/web/tests/unit/install-progress.test.mjs +++ b/web/tests/unit/install-progress.test.mjs @@ -25,7 +25,6 @@ new Function('require', 'module', 'exports', compiled)( const { InstallStage, - INSTALL_PROGRESS_CAP, STAGE_PROGRESS_RANGE, computeStageProgress, mapActionToStage, @@ -92,27 +91,22 @@ test('measured byte counts stay inside the download stage range', () => { ); }); -test('fallback drift never spills into the next stage range', () => { - for (const stage of [ - InstallStage.DOWNLOADING, - InstallStage.INSTALLING_DEPS, - InstallStage.INITIALIZING, - InstallStage.LAUNCHING, - ]) { - const [start, end] = STAGE_PROGRESS_RANGE[stage]; - const progress = computeStageProgress({ - stage, - stageElapsedSeconds: 100000, - }); +test('fallback drift advances within fixed stage boundaries', () => { + const stages = [ + [InstallStage.DOWNLOADING, 5, 25, 44], + [InstallStage.INSTALLING_DEPS, 45, 65, 84], + [InstallStage.INITIALIZING, 85, 87, 87], + [InstallStage.LAUNCHING, 88, 92, 96], + ]; - assert.ok( - progress >= start && progress <= end, - `stage ${stage} produced ${progress}, outside [${start}, ${end}]`, - ); - assert.ok( - progress < INSTALL_PROGRESS_CAP, - `stage ${stage} must not reach the completion cap on drift alone`, - ); + for (const [stage, start, midpoint, ceiling] of stages) { + const progressAt = (stageElapsedSeconds) => + computeStageProgress({ stage, stageElapsedSeconds }); + assert.equal(progressAt(0), start); + assert.equal(progressAt(1), start); + assert.equal(progressAt(4), start + 2); + assert.equal(progressAt((midpoint - start) * 2), midpoint); + assert.equal(progressAt(100000), ceiling); } }); @@ -173,18 +167,24 @@ test('measured bytes override Host coarse progress and fallback stays bounded', } }); -test('a missing or zero download total falls back to bounded drift', () => { - const [start, end] = STAGE_PROGRESS_RANGE[InstallStage.DOWNLOADING]; - - const progress = computeStageProgress({ - stage: InstallStage.DOWNLOADING, - downloadCurrent: 10, - downloadTotal: 0, - stageElapsedSeconds: 100000, - }); - - assert.ok( - progress >= start && progress <= end, - `expected progress within [${start}, ${end}], received ${progress}`, - ); +test('a missing or zero download total advances using fallback drift', () => { + for (const downloadTotal of [undefined, 0]) { + for (const [stageElapsedSeconds, expected] of [ + [0, 5], + [4, 7], + [20, 15], + [100000, 44], + ]) { + assert.equal( + computeStageProgress({ + stage: InstallStage.DOWNLOADING, + downloadCurrent: 10, + downloadTotal, + stageElapsedSeconds, + }), + expected, + `download total ${downloadTotal}, elapsed ${stageElapsedSeconds}s`, + ); + } + } }); diff --git a/web/tests/unit/knowledge-engine-marketplace.test.mjs b/web/tests/unit/knowledge-engine-marketplace.test.mjs index a803ba3c3..d75f34ceb 100644 --- a/web/tests/unit/knowledge-engine-marketplace.test.mjs +++ b/web/tests/unit/knowledge-engine-marketplace.test.mjs @@ -1,146 +1,264 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import path from 'node:path'; import test from 'node:test'; -import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; -const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); -const webRoot = path.resolve(currentDirectory, '../..'); - -function readSource(relativePath) { - return fs.readFileSync(path.join(webRoot, relativePath), 'utf8'); +function setup({ + tasks = [{ runtime: { done: true } }], + engines = [{ engines: [] }], + cloud, +} = {}) { + const source = fs.readFileSync( + new URL( + '../../src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts', + import.meta.url, + ), + 'utf8', + ); + const code = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + const storage = new Map(), + calls = []; + let workspace = 'alpha', + now = 0, + taskIndex = 0, + engineIndex = 0; + const http = { + getPlugins: async () => ({ + plugins: [ + { + manifest: { + manifest: { metadata: { author: 'qa', name: 'existing' } }, + }, + }, + ], + }), + installPluginFromMarketplace: async (...args) => { + calls.push(['install', ...args]); + return { task_id: 42 }; + }, + getAsyncTask: async (id) => { + calls.push(['task', id]); + return tasks[Math.min(taskIndex++, tasks.length - 1)]; + }, + getKnowledgeEngines: async () => { + calls.push(['engines']); + return engines[Math.min(engineIndex++, engines.length - 1)]; + }, + }; + const deps = { + '@/app/infra/http': { getCloudServiceClient: async () => cloud }, + '@/app/infra/http/HttpClient': { httpClient: http }, + '@/app/infra/http/workspaceContext': { + getActiveWorkspaceUuid: () => workspace, + }, + }; + const mod = { exports: {} }; + new Function( + 'require', + 'module', + 'exports', + 'window', + 'sessionStorage', + 'CustomEvent', + 'Date', + 'setTimeout', + code, + )( + (name) => { + assert.ok(name in deps, name); + return deps[name]; + }, + mod, + mod.exports, + new EventTarget(), + { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }, + CustomEvent, + { now: () => now }, + (callback, ms) => { + now += ms; + queueMicrotask(callback); + }, + ); + return { + api: mod.exports, + storage, + calls, + setWorkspace: (value) => { + workspace = value; + }, + }; } +const plugin = { + author: 'qa', + name: 'engine', + label: { en_US: 'QA Engine' }, + latest_version: '2.0.0', +}; +const engine = { plugin_id: 'qa/engine', name: 'Registered QA Engine' }; -const kbFormSource = readSource( - 'src/app/home/knowledge/components/kb-form/KBForm.tsx', -); -const selectSource = readSource( - 'src/app/home/knowledge/components/kb-form/KnowledgeEngineSelect.tsx', -); -const runnerSelectSource = readSource( - 'src/app/home/agents/components/RunnerSelect.tsx', -); -const agentFormSource = readSource( - 'src/app/home/agents/components/AgentFormComponent.tsx', -); -const pipelineFormSource = readSource( - 'src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx', -); -const runnerMarketplaceSource = readSource( - 'src/app/home/agents/runner-marketplace.ts', -); -const marketplaceInstallButtonSource = readSource( - 'src/app/home/components/MarketplaceInstallButton.tsx', -); -const marketplaceSource = readSource( - 'src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts', -); -const taskContextSource = readSource( - 'src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx', -); -const progressDialogSource = readSource( - 'src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx', -); -const installedPluginsSource = readSource( - 'src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx', -); -const homeSidebarSource = readSource( - 'src/app/home/components/home-sidebar/HomeSidebar.tsx', -); - -test('keeps the knowledge-base fields visible without an installed engine', () => { - assert.match(kbFormSource, / { - assert.match( - marketplaceSource, - /KNOWLEDGE_ENGINE_COMPONENT_FILTER = 'KnowledgeEngine'/, +test('catalog loads every page with the engine filter and excludes unrelated components', async () => { + const searches = []; + const { api } = setup({ + cloud: { + searchMarketplaceExtensions: async (args) => { + searches.push(args); + return { + total: 201, + plugins: + args.page === 1 + ? [ + { + ...plugin, + components: { KnowledgeEngine: 1 }, + install_count: 5, + }, + ] + : args.page === 2 + ? [ + { + name: 'runner', + components: { Runner: 1 }, + install_count: 999, + }, + ] + : [ + { + ...plugin, + name: 'popular', + components: { KnowledgeEngine: 1 }, + install_count: 20, + }, + ], + }; + }, + }, + }); + const result = await api.loadKnowledgeEngineCatalog(); + assert.deepEqual( + searches.map((value) => value.page), + [1, 2, 3], ); - assert.match(marketplaceSource, /installPluginFromMarketplace\(/); - assert.match(marketplaceSource, /getKnowledgeEngines\(\)/); - assert.match(marketplaceSource, /sessionStorage\.setItem\(/); - assert.match(selectSource, /resumePendingKnowledgeEngineInstall\(/); - assert.match(selectSource, /onInstalled\(installed\)/); - assert.match(selectSource, /component=KnowledgeEngine/); -}); - -test('tracks plugin upgrades as recoverable multistep async tasks', () => { - assert.match(taskContextSource, /name\.startsWith\('plugin-upgrade-'\)/); - assert.match(taskContextSource, /operation: PluginTaskOperation/); - assert.match(taskContextSource, /computeStageProgress/); - assert.match(taskContextSource, /INSTALL_PROGRESS_CAP/); - assert.match(progressDialogSource, /InstallStage\.CHECKING/); - assert.match(progressDialogSource, /InstallStage\.VALIDATING/); - assert.match(progressDialogSource, /InstallStage\.LAUNCHING/); - assert.match(progressDialogSource, /plugins\.installProgress\.updateTitle/); - for (const source of [installedPluginsSource, homeSidebarSource]) { - assert.match( - source, - /upgradePlugin\([\s\S]*?add(?:Plugin)?Task\([\s\S]*?operation: 'upgrade'/, - ); - assert.match( - source, - /pluginTaskKey\(res\.task_id, 'marketplace', 'upgrade'\)/, - ); - } -}); - -test('keeps marketplace install actions on one fixed vertical column', () => { - for (const source of [selectSource, runnerSelectSource]) { - assert.match(source, /grid-cols-\[1\.75rem_minmax\(0,1fr\)_4rem\]/); - assert.match(source, / { - assert.match(selectSource, /function SelectedEngineContent/); - assert.match( - selectSource, - /selectedEngine \? \([\s\S]*? value.name), + ['popular', 'engine'], ); - assert.match( - selectSource, - /flex min-w-0 flex-1 items-center gap-2 text-left/, - ); - assert.match(kbFormSource, /form\.clearErrors\('ragEngineId'\)/); - assert.match(kbFormSource, /form\.trigger\('ragEngineId'\)/); - assert.doesNotMatch(kbFormSource, /field\.onChange\(value\)/); + assert.deepEqual(result.installedPluginIds, ['qa/existing']); }); -test('installs marketplace components from inline progress buttons without selecting them', () => { - for (const source of [selectSource, runnerSelectSource]) { - assert.match(source, /const handleInstall = useCallback/); - assert.match(source, /installing=\{activePluginId === pluginId\}/); - assert.match(source, /progress=\{installProgress\}/); - assert.doesNotMatch(source, /MARKETPLACE_VALUE_PREFIX/); - } - assert.match(marketplaceInstallButtonSource, / { + const { api, storage, calls } = setup({ + tasks: [{ runtime: { done: false } }, { runtime: { done: true } }], + engines: [{ engines: [{ plugin_id: 'qa/other' }] }, { engines: [engine] }], + }); + let created; + const installed = await api.installMarketplaceKnowledgeEngine(plugin, { + scope: 'kb:new', + onTaskCreated: (id) => { + created = id; + assert.equal(api.readPendingKnowledgeEngineInstall('kb:new').taskId, 42); + }, + }); + assert.equal(installed, engine); + assert.equal(created, 42); + assert.deepEqual(calls, [ + ['install', 'qa', 'engine', '2.0.0'], + ['task', 42], + ['task', 42], + ['engines'], + ['engines'], + ]); + assert.equal(storage.size, 0); }); -test('shows plugin descriptions for installed Runner entries', () => { - assert.match(runnerMarketplaceSource, /installedPluginDescriptions/); - assert.match(runnerMarketplaceSource, /metadata\.description/); - assert.match(runnerSelectSource, /installedRunnerDescription\(/); - assert.match(runnerSelectSource, /function InstalledRunnerOptionContent/); - assert.match(runnerSelectSource, /marketplacePlugin\?\.description/); - assert.match(runnerSelectSource, /grid-cols-\[1\.75rem_minmax\(0,1fr\)\]/); - assert.doesNotMatch(runnerSelectSource, /description=\{option\.name\}/); +test('a pending engine install resumes only in its original workspace and form scope', async () => { + const { api, storage, calls, setWorkspace } = setup({ + engines: [{ engines: [engine] }], + }); + storage.set( + 'langbot-knowledge-engine-install:alpha:kb:new', + JSON.stringify({ + taskId: 42, + pluginId: 'qa/engine', + pluginAuthor: 'qa', + pluginName: 'engine', + pluginLabel: 'QA Engine', + scope: 'kb:new', + startedAt: 0, + }), + ); + setWorkspace('beta'); + assert.equal(await api.resumePendingKnowledgeEngineInstall('kb:new'), null); + setWorkspace('alpha'); + assert.equal(await api.resumePendingKnowledgeEngineInstall('kb:other'), null); + assert.equal(await api.resumePendingKnowledgeEngineInstall('kb:new'), engine); + assert.deepEqual(calls, [['task', 42], ['engines']]); + assert.equal(storage.size, 0); +}); + +test('failed tasks clear their intent and never query component registration', async () => { + const { api, calls, storage } = setup({ + tasks: [{ runtime: { done: true, exception: 'Package invalid' } }], + }); + await assert.rejects( + api.installMarketplaceKnowledgeEngine(plugin, { scope: 'kb:new' }), + /Package invalid/, + ); + assert.deepEqual(calls, [ + ['install', 'qa', 'engine', '2.0.0'], + ['task', 42], + ]); + assert.equal(storage.size, 0); +}); + +test('an unfinished task times out with a recoverable intent, while missing registration clears it', async () => { + const pending = setup({ tasks: [{ runtime: { done: false } }] }); + await assert.rejects( + pending.api.installMarketplaceKnowledgeEngine(plugin, { scope: 'kb:new' }), + (error) => error.code === 'install-timeout', + ); + assert.equal( + pending.api.readPendingKnowledgeEngineInstall('kb:new').taskId, + 42, + ); + assert.equal( + pending.calls.some((value) => value[0] === 'engines'), + false, + ); + const unregistered = setup(); + await assert.rejects( + unregistered.api.installMarketplaceKnowledgeEngine(plugin, { + scope: 'kb:new', + }), + (error) => error.code === 'registration-timeout', + ); + assert.equal(unregistered.storage.size, 0); +}); + +test('a plugin without a version never starts an install task', async () => { + const { api, calls } = setup(); + await assert.rejects( + api.installMarketplaceKnowledgeEngine( + { ...plugin, latest_version: '' }, + { scope: 'kb:new' }, + ), + (error) => error.code === 'version-unavailable', + ); + assert.deepEqual(calls, []); }); diff --git a/web/tests/unit/marketplace-likes.test.mjs b/web/tests/unit/marketplace-likes.test.mjs index 09283e628..38e8c8972 100644 --- a/web/tests/unit/marketplace-likes.test.mjs +++ b/web/tests/unit/marketplace-likes.test.mjs @@ -1,86 +1,157 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import path from 'node:path'; import test from 'node:test'; -import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; -const webRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../..', -); -const read = (relativePath) => - fs.readFileSync(path.join(webRoot, relativePath), 'utf8'); +function load(relative, deps, globals = {}) { + const source = fs.readFileSync( + new URL('../../src/' + relative, import.meta.url), + 'utf8', + ); + const code = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + const mod = { exports: {} }; + new Function('require', 'module', 'exports', ...Object.keys(globals), code)( + (name) => { + assert.ok(name in deps, name); + return deps[name]; + }, + mod, + mod.exports, + ...Object.values(globals), + ); + return mod.exports; +} -test('marketplace defaults to shared hot sorting and cards support fingerprint likes', () => { - const market = read( - 'src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx', +test('likes reuse the browser fingerprint, update cached state and notify only until unsubscribed', async () => { + const calls = [], + events = []; + const client = { + getMarketplaceLikedExtensions: async (fingerprint) => { + calls.push(['status', fingerprint]); + return { + extensions: [{ type: 'plugin', extension_id: 'qa/already-liked' }], + }; + }, + setMarketplaceExtensionLike: async (...args) => { + calls.push(['toggle', ...args]); + return { liked: args[4], like_count: args[4] ? 8 : 7 }; + }, + }; + const api = load( + 'app/home/plugins/components/plugin-market/marketplace-likes.ts', + { + '@fingerprintjs/fingerprintjs': { + default: { + load: async (options) => { + calls.push(['fingerprint', options]); + return { get: async () => ({ visitorId: 'browser-id' }) }; + }, + }, + }, + '@/app/infra/http': { getCloudServiceClient: async () => client }, + }, + { window: new EventTarget(), CustomEvent }, ); - const card = read( - 'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx', + assert.equal( + await api.getMarketplaceExtensionLiked(undefined, 'qa', 'already-liked'), + true, ); - const cardVO = read( - 'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts', + assert.equal( + await api.getMarketplaceExtensionLiked('skill', 'qa', 'already-liked'), + false, ); - const entity = read('src/app/infra/entities/plugin/index.ts'); - const client = read('src/app/infra/http/CloudServiceClient.ts'); - const likes = read( - 'src/app/home/plugins/components/plugin-market/marketplace-likes.ts', + const unsubscribe = api.subscribeMarketplaceLikeChanges((change) => + events.push(change), ); - - assert.match( - market, - /hot_score_desc/, - 'market must expose and default to hot-score sorting', + assert.deepEqual( + await api.toggleMarketplaceExtensionLike(undefined, 'qa', 'new', true), + { key: 'plugin:qa/new', liked: true, likeCount: 8 }, ); - assert.match( - market, - /sortBy:\s*['"]hot_score['"]/, - 'hot option must use the shared API field', + assert.equal( + await api.getMarketplaceExtensionLiked('plugin', 'qa', 'new'), + true, ); - assert.match( - entity, - /like_count\??:\s*number/, - 'marketplace extension responses must carry like counts', - ); - assert.match( - cardVO, - /likeCount:\s*number/, - 'market cards must carry like counts', - ); - assert.match(card, /\bHeart\b/, 'market cards must render a like affordance'); - assert.match( - card, - /toggleMarketplaceExtensionLike/, - 'market cards must support liking and unliking', - ); - assert.match( - client, - /marketplace\/extensions\/likes/, - 'client must load browser likes', - ); - assert.match( - client, - /setMarketplaceExtensionLike\(/, - 'client must update likes through the shared API', - ); - assert.match( - client, - /data\.sort_by === ['"]hot_score['"] \? ['"]install_count['"] : data\.sort_by/, - 'older Space servers must fall back from hot score to install sorting', - ); - assert.match( - likes, - /@fingerprintjs\/fingerprintjs/, - 'anonymous likes must use browser fingerprinting', - ); - assert.match( - likes, - /FingerprintJS\.load\(\{/, - 'fingerprint agent must be initialized with options', - ); - assert.match( - likes, - /monitoring:\s*false/, - 'fingerprinting must not send vendor monitoring requests', + await api.toggleMarketplaceExtensionLike('plugin', 'qa', 'new', false); + assert.equal( + await api.getMarketplaceExtensionLiked('plugin', 'qa', 'new'), + false, ); + unsubscribe(); + await api.toggleMarketplaceExtensionLike('skill', 'qa', 'new', true); + assert.deepEqual(events, [ + { key: 'plugin:qa/new', liked: true, likeCount: 8 }, + { key: 'plugin:qa/new', liked: false, likeCount: 7 }, + ]); + assert.deepEqual(calls, [ + ['fingerprint', { monitoring: false }], + ['status', 'browser-id'], + ['toggle', 'plugin', 'qa', 'new', 'browser-id', true], + ['toggle', 'plugin', 'qa', 'new', 'browser-id', false], + ['toggle', 'skill', 'qa', 'new', 'browser-id', true], + ]); +}); + +test('legacy marketplace search converts hot sorting while preserving filters', async () => { + const calls = []; + class BaseHttpClient { + async post(path, body) { + calls.push([path, body]); + if (path === '/api/v1/marketplace/extensions/search') + throw new Error('Legacy server'); + return { + plugins: [{ author: 'qa', name: 'legacy', type: 'plugin' }], + total: 1, + }; + } + } + const { CloudServiceClient } = load('app/infra/http/CloudServiceClient.ts', { + './BaseHttpClient': { BaseHttpClient }, + '@/app/infra/entities/plugin': {}, + }); + const result = await new CloudServiceClient().searchMarketplaceExtensions({ + query: 'needle', + page: 2, + page_size: 10, + sort_by: 'hot_score', + sort_order: 'DESC', + type_filter: 'plugin', + tags_filter: ['qa'], + }); + assert.deepEqual( + result.plugins.map((value) => value.name), + ['legacy'], + ); + assert.deepEqual(calls, [ + [ + '/api/v1/marketplace/extensions/search', + { + query: 'needle', + page: 2, + page_size: 10, + sort_by: 'hot_score', + sort_order: 'DESC', + type_filter: 'plugin', + tags_filter: ['qa'], + }, + ], + [ + '/api/v1/marketplace/plugins/search', + { + query: 'needle', + page: 2, + page_size: 10, + sort_by: 'install_count', + sort_order: 'DESC', + component_filter: undefined, + tags_filter: ['qa'], + type_filter: 'plugin', + runner_usage: undefined, + }, + ], + ]); }); diff --git a/web/tests/unit/oss-account-space-billing.test.mjs b/web/tests/unit/oss-account-space-billing.test.mjs index e49cf43cb..e5c683223 100644 --- a/web/tests/unit/oss-account-space-billing.test.mjs +++ b/web/tests/unit/oss-account-space-billing.test.mjs @@ -10,45 +10,6 @@ const root = path.resolve( ); const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); -test('invited local registration returns to login instead of authenticating', () => { - const source = read('src/app/invitations/accept/page.tsx'); - assert.doesNotMatch( - source, - /beginAuthenticatedSession\([\s\S]{0,120}response\.token/, - ); - assert.match(source, /navigate\('\/login\?invitation=1'/); -}); - -test('authenticated invitation page offers logout while retaining invitation', () => { - const source = read('src/app/invitations/accept/page.tsx'); - assert.match(source, /workspace\.logoutAndReturn/); - assert.match(source, /setPendingInvitationToken\(token\)/); -}); - -test('Space OAuth callback distinguishes unknown and unbound accounts by stable codes', () => { - const source = read('src/app/auth/space/callback/page.tsx'); - assert.match(source, /space_account_not_registered/); - assert.match(source, /space_account_binding_required/); -}); - -test('models panel derives LangBot Models billing state from workspace owner', () => { - const source = read('src/app/home/components/models-dialog/ModelsPanel.tsx'); - assert.match(source, /getWorkspaceSpaceBilling/); - assert.doesNotMatch(source, /getSpaceCredits\(\)/); - assert.match(source, /membership\.role === 'owner'/); -}); - -test('provider card represents owner and member owner-bound states explicitly', () => { - const source = read( - 'src/app/home/components/models-dialog/components/ProviderCard.tsx', - ); - assert.match(source, /isWorkspaceOwner/); - assert.match(source, /ownerSpaceBound/); - assert.match(source, /models\.ownerMustBindSpace/); - assert.match(source, /models\.usesOwnerSpaceBilling/); - assert.match(source, /isWorkspaceOwner && \(\s*