From 342d8436227e07ef7e529546ad8f45d5c07d52bd Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:08:51 +0000 Subject: [PATCH 01/38] test(web): replace source guards with browser behavior checks --- web/tests/e2e/login.spec.ts | 81 +++++++++++++++++++ .../unit/session-monitor-pagination.test.mjs | 15 ---- web/tests/unit/support-admin-session.test.mjs | 42 ---------- 3 files changed, 81 insertions(+), 57 deletions(-) delete mode 100644 web/tests/unit/support-admin-session.test.mjs 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/unit/session-monitor-pagination.test.mjs b/web/tests/unit/session-monitor-pagination.test.mjs index 38b09f306..cdb0f8812 100644 --- a/web/tests/unit/session-monitor-pagination.test.mjs +++ b/web/tests/unit/session-monitor-pagination.test.mjs @@ -87,21 +87,6 @@ test('backend filters sessions by user id or user name in the existing endpoint' ); }); -test('stale session and message page responses cannot overwrite the latest page', () => { - const monitor = read( - 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', - ); - for (const token of [ - 'sessionRequestIdRef', - 'messageRequestIdRef', - 'requestId !== sessionRequestIdRef.current', - 'requestId !== messageRequestIdRef.current', - 'messageRequestIdRef.current += 1', - ]) { - includes(monitor, token, `stale response guard includes ${token}`); - } -}); - test('changing the session page or filters clears the selected detail', () => { const monitor = read( 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', diff --git a/web/tests/unit/support-admin-session.test.mjs b/web/tests/unit/support-admin-session.test.mjs deleted file mode 100644 index 3e5135a77..000000000 --- a/web/tests/unit/support-admin-session.test.mjs +++ /dev/null @@ -1,42 +0,0 @@ -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'; - -const root = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../..', -); -const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); - -test('support-admin launch stores a scoped principal instead of starting an Account session', () => { - const callback = read('src/app/auth/space/callback/page.tsx'); - assert.match(callback, /response\.principal_type === 'support_admin'/); - assert.match( - callback, - /beginSupportAdminSession\(response\.token, response\.workspace_uuid\)/, - ); -}); - -test('support-admin workspace bootstrap never calls Account bootstrap', () => { - const source = read('src/app/infra/http/index.ts'); - assert.match(source, /export function beginSupportAdminSession/); - assert.match(source, /export function isSupportAdminSession\(\): boolean/); - const supportBranch = source.indexOf('if (isSupportAdminSession())'); - const accountBootstrap = source.indexOf( - 'backendClient.getWorkspaceBootstrap()', - ); - assert.ok(supportBranch >= 0); - assert.ok(accountBootstrap > supportBranch); - assert.match( - source.slice(supportBranch, accountBootstrap), - /initializeWorkspaceInfo\([\s\S]*status: 'ready'/, - ); - assert.match( - source, - /localStorage\.setItem\('authPrincipalType', 'support_admin'\)/, - ); - const homeLayout = read('src/app/home/layout.tsx'); - assert.match(homeLayout, /!isSupportAdminSession\(\)/); -}); From b5ea5f88c36ddcfe7b37ee33218a35be417432a0 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:06:33 +0000 Subject: [PATCH 02/38] test(web): verify workspace switching and quota failure boundaries --- web/tests/e2e/plugin-page-auth.spec.ts | 150 +++++++++++++++ web/tests/e2e/quota-create-actions.spec.ts | 189 +++++++++++++++++++ web/tests/unit/quota-create-actions.test.mjs | 15 -- 3 files changed, 339 insertions(+), 15 deletions(-) diff --git a/web/tests/e2e/plugin-page-auth.spec.ts b/web/tests/e2e/plugin-page-auth.spec.ts index 384567120..ce8ab0a88 100644 --- a/web/tests/e2e/plugin-page-auth.spec.ts +++ b/web/tests/e2e/plugin-page-auth.spec.ts @@ -139,3 +139,153 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({ 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(); + } + }); +} 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/unit/quota-create-actions.test.mjs b/web/tests/unit/quota-create-actions.test.mjs index 8bea7a364..49a1d63c2 100644 --- a/web/tests/unit/quota-create-actions.test.mjs +++ b/web/tests/unit/quota-create-actions.test.mjs @@ -99,21 +99,6 @@ test('add-extension page disables all install entry points at the quota', () => assert.match(page, /quota=\{extensionQuota\}/); }); -test('extension confirmation checks fail closed and enter an in-flight state first', () => { - const page = fs.readFileSync(addExtensionPath, 'utf8'); - - assert.match(page, /limitation\.quotaCheckFailed/); - assert.doesNotMatch(page, /If we can't check, let backend handle it/); - assert.match( - page, - /setGithubInstallStatus\(GithubInstallStatus\.INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/, - ); - assert.match( - page, - /setGithubInstallStatus\(GithubInstallStatus\.SKILL_INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/, - ); -}); - test('quota tooltip copy is localized in Simplified Chinese', () => { const source = fs.readFileSync(zhPath, 'utf8'); assert.match(source, /createDisabledTooltip/); From 8d3e99d0fb2f94eb79cc570383a262b2dc8e10ce Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:58:17 +0800 Subject: [PATCH 03/38] test(core): strengthen config and task behavior oracles --- tests/unit_tests/core/test_bootutils_deps.py | 6 +-- tests/unit_tests/core/test_load_config.py | 11 +++-- tests/unit_tests/core/test_taskmgr.py | 46 +++++++++++++------- 3 files changed, 38 insertions(+), 25 deletions(-) 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_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_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.""" From 20daecc9cf2b5f6a58057626570c9974c4b98051 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:00:41 +0800 Subject: [PATCH 04/38] test(web): replace self-confirming progress checks and remove duplicates --- web/tests/e2e/crud-smoke.spec.ts | 36 ---------- web/tests/unit/install-progress.test.mjs | 70 ++++++++++---------- web/tests/unit/oss-cloud-ui-privacy.test.mjs | 12 ---- 3 files changed, 35 insertions(+), 83 deletions(-) diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index ba0d3b176..004b8cc12 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -1464,39 +1464,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/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/oss-cloud-ui-privacy.test.mjs b/web/tests/unit/oss-cloud-ui-privacy.test.mjs index 41a81e375..4fd65d326 100644 --- a/web/tests/unit/oss-cloud-ui-privacy.test.mjs +++ b/web/tests/unit/oss-cloud-ui-privacy.test.mjs @@ -31,18 +31,6 @@ const authenticatedPluginResourceSource = readSource( 'src/hooks/useAuthenticatedPluginResource.ts', ); -test('hides the entire workspace switcher slot for a singleton local workspace', () => { - assert.match(homeSidebarSource, /useWorkspaceBootstrap/); - assert.match( - homeSidebarSource, - /const showWorkspaceSwitcher\s*=\s*workspaces\.length\s*>\s*1\s*\|\|\s*currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'/, - ); - assert.match( - homeSidebarSource, - /\{showWorkspaceSwitcher\s*&&\s*\(\s*
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/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'] From 8bad1a14fe97ae3cd0abae7d5889a06be740b365 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:02:41 +0800 Subject: [PATCH 06/38] test(api): verify resource payloads and scoped service arguments --- tests/integration/api/test_knowledge.py | 103 +++++++++++++++++--- tests/integration/api/test_pipelines.py | 109 ++++++++++++++++++--- tests/integration/api/test_providers.py | 122 ++++++++++++++++++++---- 3 files changed, 295 insertions(+), 39 deletions(-) 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_pipelines.py b/tests/integration/api/test_pipelines.py index 9f84aafb9..5e06388c8 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, 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'} From 4afd86c1f666a22e7c0fc0f7fe794651f017f804 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:03:50 +0800 Subject: [PATCH 07/38] test: consolidate duplicate backend contracts and strengthen stage checks --- tests/integration/api/test_smoke.py | 10 --- .../integration/api/test_user_space_oauth.py | 33 --------- .../test_migration_branch_convergence.py | 15 +--- .../persistence/test_migrations.py | 13 ---- .../persistence/test_migrations_postgres.py | 15 ---- .../agent/test_event_first_protocol.py | 10 --- .../api/service/test_apikey_service.py | 5 -- ...pipeline_migration_pending_interactions.py | 14 ---- tests/unit_tests/box/test_box_service.py | 15 ---- tests/unit_tests/core/test_stage.py | 35 ++------- .../persistence/test_database_decorator.py | 39 +--------- .../unit_tests/plugin/test_connector_pure.py | 7 -- .../plugin/test_plugin_id_parsing.py | 4 -- tests/unit_tests/storage/test_s3storage.py | 13 +--- .../storage/test_storage_manager.py | 72 ------------------- 15 files changed, 11 insertions(+), 289 deletions(-) diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index 864fa374f..2b717937d 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: diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index ad49c83a6..6bd651d56 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 diff --git a/tests/integration/persistence/test_migration_branch_convergence.py b/tests/integration/persistence/test_migration_branch_convergence.py index 6020c9b37..c48f29702 100644 --- a/tests/integration/persistence/test_migration_branch_convergence.py +++ b/tests/integration/persistence/test_migration_branch_convergence.py @@ -354,6 +354,9 @@ async def test_empty_database_startup_schema_then_real_migrations(convergence_en await run_alembic_upgrade(engine, 'head') assert await get_alembic_current(engine) == get_alembic_head() + await run_alembic_upgrade(engine, 'head') + assert await get_alembic_current(engine) == get_alembic_head() + def _legacy_shared_certification(**overrides): certification = { @@ -480,18 +483,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..84c216dea 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -438,16 +438,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..ea64d8e1a 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -550,21 +550,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.""" diff --git a/tests/unit_tests/agent/test_event_first_protocol.py b/tests/unit_tests/agent/test_event_first_protocol.py index 29429d9ea..ec41da89e 100644 --- a/tests/unit_tests/agent/test_event_first_protocol.py +++ b/tests/unit_tests/agent/test_event_first_protocol.py @@ -332,16 +332,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.""" 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_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/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 1d680ba7a..1d25299de 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -1884,21 +1884,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) diff --git a/tests/unit_tests/core/test_stage.py b/tests/unit_tests/core/test_stage.py index e9b29e76b..e9b33fb4b 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 @@ -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/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/plugin/test_connector_pure.py b/tests/unit_tests/plugin/test_connector_pure.py index 337ec16a1..4ed20f1cc 100644 --- a/tests/unit_tests/plugin/test_connector_pure.py +++ b/tests/unit_tests/plugin/test_connector_pure.py @@ -147,13 +147,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_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/storage/test_s3storage.py b/tests/unit_tests/storage/test_s3storage.py index 7e90cf43d..a571daa7e 100644 --- a/tests/unit_tests/storage/test_s3storage.py +++ b/tests/unit_tests/storage/test_s3storage.py @@ -57,14 +57,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() @@ -232,7 +224,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 +233,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 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()) From a2066c95e711e5e3ba389a3f1606154afc384f9a Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:07:52 +0800 Subject: [PATCH 08/38] test(io): verify storage config and utility results --- tests/unit_tests/config/test_config_loader.py | 9 +++--- tests/unit_tests/storage/test_s3storage.py | 23 +++++++++---- tests/unit_tests/utils/test_image.py | 4 +-- tests/unit_tests/utils/test_importutil.py | 32 +++++++++---------- tests/unit_tests/utils/test_paths.py | 16 ++++------ 5 files changed, 46 insertions(+), 38 deletions(-) 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/storage/test_s3storage.py b/tests/unit_tests/storage/test_s3storage.py index 7e90cf43d..6fcbdf423 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(): @@ -102,6 +103,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 +114,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): @@ -325,8 +332,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 +358,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/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 From bcfe78aa0ecb8cd5b9a3442a0502140b441936f6 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:12:25 +0800 Subject: [PATCH 09/38] test(pipeline): verify message and provider behavior --- tests/unit_tests/pipeline/test_aggregator.py | 75 +++++++++++++++---- .../unit_tests/pipeline/test_chat_handler.py | 44 +++++++++-- .../pipeline/test_command_handler.py | 4 +- tests/unit_tests/pipeline/test_longtext.py | 16 ++-- tests/unit_tests/pipeline/test_preproc.py | 39 +++++++--- tests/unit_tests/pipeline/test_ratelimit.py | 9 ++- tests/unit_tests/pipeline/test_wrapper.py | 35 ++++++--- tests/unit_tests/provider/test_litellmchat.py | 10 +-- .../provider/test_requester_base.py | 24 +++--- .../provider/test_session_manager.py | 5 +- tests/unit_tests/vector/test_filter_utils.py | 4 +- 11 files changed, 186 insertions(+), 79 deletions(-) diff --git a/tests/unit_tests/pipeline/test_aggregator.py b/tests/unit_tests/pipeline/test_aggregator.py index 24141b3ec..f11370a96 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): @@ -658,10 +688,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 +787,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 +864,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 +875,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 +887,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 +914,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..fa4a3b969 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 @@ -223,7 +242,9 @@ class TestChatMessageHandlerReal: assert query.user_message.content is not None @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 +258,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 +278,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 +339,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 +354,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 +365,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 +568,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 46cccadad..c520c060c 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: @@ -253,6 +254,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() @@ -276,8 +278,13 @@ 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_plugin.api.entities.builtin.provider.message import ContentElement + + assert result.new_query.user_message.content == [ + ContentElement.from_text('look at this'), + ContentElement.from_image_base64('data:image/png;base64,abc123'), + ] @pytest.mark.asyncio async def test_image_without_vision_model(self): @@ -299,6 +306,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() @@ -311,6 +319,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: @@ -455,11 +469,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): @@ -493,8 +514,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/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..851f59a55 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(): @@ -251,13 +248,13 @@ def test_runtime_provider_initialization(runtime_provider, fake_persistence_data def test_runtime_provider_has_invoke_methods(runtime_provider): - """Test RuntimeProvider has invoke methods that delegate to requester.""" + """RuntimeProvider exposes callable invocation entrypoints.""" provider = runtime_provider - assert hasattr(provider, 'invoke_llm') - assert hasattr(provider, 'invoke_llm_stream') - assert hasattr(provider, 'invoke_embedding') - assert hasattr(provider, 'invoke_rerank') + assert callable(provider.invoke_llm) + assert callable(provider.invoke_llm_stream) + assert callable(provider.invoke_embedding) + assert callable(provider.invoke_rerank) @pytest.mark.asyncio @@ -404,6 +401,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 +511,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/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) From c1ab2d1110c1b64754d64faf1181c4fc13a648a0 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:13:08 +0800 Subject: [PATCH 10/38] test(services): verify persisted state and delivery boundaries --- .../cloud/test_directory_projection.py | 61 ++++++++++++++----- tests/unit_tests/command/test_cmdmgr.py | 6 +- tests/unit_tests/rag/test_file_storage.py | 8 ++- tests/unit_tests/rag/test_kbmgr.py | 4 +- .../unit_tests/survey/test_survey_manager.py | 11 +++- tests/unit_tests/telemetry/test_telemetry.py | 13 ++-- .../workspace/test_invitation_delivery.py | 28 ++++++--- 7 files changed, 96 insertions(+), 35 deletions(-) diff --git a/tests/unit_tests/cloud/test_directory_projection.py b/tests/unit_tests/cloud/test_directory_projection.py index 756baa7be..22fbc5804 100644 --- a/tests/unit_tests/cloud/test_directory_projection.py +++ b/tests/unit_tests/cloud/test_directory_projection.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import datetime import logging +import sqlite3 from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -184,7 +185,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 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 +219,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 +259,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 +580,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 +598,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 +760,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 +777,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 +790,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/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/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/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 From d25c1e0f0cfb29ddc39e8ea1ec267fe6652f31ad Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:16:26 +0800 Subject: [PATCH 11/38] test(api): verify monitoring payloads and embed side effects --- tests/integration/api/test_embed.py | 98 +++++++++++++++- tests/integration/api/test_monitoring.py | 135 ++++++++++++++++++++++- 2 files changed, 221 insertions(+), 12 deletions(-) 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_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 From ce6465094e998764d9e4e8ca027d7fd63c190de3 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:18:01 +0800 Subject: [PATCH 12/38] test(provider): fold method presence check into invocation tests --- tests/unit_tests/provider/test_requester_base.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/unit_tests/provider/test_requester_base.py b/tests/unit_tests/provider/test_requester_base.py index 851f59a55..e909a1a24 100644 --- a/tests/unit_tests/provider/test_requester_base.py +++ b/tests/unit_tests/provider/test_requester_base.py @@ -247,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): - """RuntimeProvider exposes callable invocation entrypoints.""" - provider = runtime_provider - - assert callable(provider.invoke_llm) - assert callable(provider.invoke_llm_stream) - assert callable(provider.invoke_embedding) - assert callable(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.""" From 468189aaaacf918c0204cc9079d365bb38cff137 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:25:11 +0800 Subject: [PATCH 13/38] test(ci): require the consolidated migration contract --- .github/workflows/run-tests.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 1912f9c6e..fb6764ba6 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -166,12 +166,19 @@ jobs: 'test_pipeline_admission_postgres': 27, 'test_rag_document_identity': 34, 'test_rag_interrupted_ingestion': 22, - 'test_migration_branch_convergence': 7, + 'test_migration_branch_convergence': 6, '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]}' + convergence_cases = { + case.get('name') for case in cases + if case.get('classname', '').rsplit('.', 1)[-1] == 'test_migration_branch_convergence' + } + assert 'test_empty_database_startup_schema_then_real_migrations[postgresql]' in convergence_cases, ( + 'The fresh-schema migration and idempotency contract must execute' + ) unsuccessful = [ case.get('name') for case in cases if any(case.find(tag) is not None for tag in ('skipped', 'failure', 'error')) From 569dd070242103a333afcf42c53e926f720a04e9 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:25:16 +0800 Subject: [PATCH 14/38] fix(auth): reject malformed credential payloads before authentication --- .../pkg/api/http/controller/groups/user.py | 6 ++- tests/integration/api/test_smoke.py | 53 +++++++++++-------- 2 files changed, 36 insertions(+), 23 deletions(-) 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/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index 864fa374f..3daee25b7 100644 --- a/tests/integration/api/test_smoke.py +++ b/tests/integration/api/test_smoke.py @@ -241,33 +241,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') From 89ef102238411623a449a1928d8e17a780ba146e Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:25:24 +0800 Subject: [PATCH 15/38] test(cloud): close directory observer database connections --- tests/unit_tests/cloud/test_directory_projection.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/cloud/test_directory_projection.py b/tests/unit_tests/cloud/test_directory_projection.py index 22fbc5804..cc2a28e88 100644 --- a/tests/unit_tests/cloud/test_directory_projection.py +++ b/tests/unit_tests/cloud/test_directory_projection.py @@ -4,6 +4,7 @@ import asyncio import datetime import logging import sqlite3 +from contextlib import closing from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -188,7 +189,7 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti observed_revisions = [] def observe_committed_revision(): - with sqlite3.connect(application.persistence_mgr.engine.url.database) as connection: + 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() From 69a415e562a65cc3915fa7176101e251e8deac11 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:26:38 +0800 Subject: [PATCH 16/38] test(models): verify model persistence and runtime boundaries --- .../test_cloud_managed_model_protection.py | 4 +- .../api/service/test_model_service.py | 161 +++++++++++++++--- 2 files changed, 139 insertions(+), 26 deletions(-) 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_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: From efe1e4a7589625ac97980b4fcf7cd60c94d49902 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:29:39 +0800 Subject: [PATCH 17/38] test(auth): verify revocation and authorization ordering --- tests/integration/api/test_pipelines.py | 37 ++++++++++++++++++- .../api/test_support_admin_launch.py | 14 +++++-- .../integration/api/test_user_space_oauth.py | 26 ++++++++++++- tests/integration/api/test_workspaces.py | 6 +++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/tests/integration/api/test_pipelines.py b/tests/integration/api/test_pipelines.py index 9f84aafb9..946725931 100644 --- a/tests/integration/api/test_pipelines.py +++ b/tests/integration/api/test_pipelines.py @@ -377,7 +377,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 +427,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 +442,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_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..cc351002f 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -423,6 +423,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 +560,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 +582,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 +596,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 +627,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), From ca997e6faa34059a477d8feee1ff41febe0acf12 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:29:54 +0800 Subject: [PATCH 18/38] test(api): verify provider persistence and returned service payloads --- .../api/service/test_agent_service.py | 4 ++ .../api/service/test_knowledge_service.py | 13 +++- .../api/service/test_provider_service.py | 69 +++++++++++++++---- .../api/test_provider_controller_secrets.py | 15 ++-- 4 files changed, 82 insertions(+), 19 deletions(-) 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_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_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/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( From e0c738557afda0a288a5dd89a2ef4b2d198f0ba0 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:35:33 +0800 Subject: [PATCH 19/38] test(agent): verify persistence and denied action side effects --- tests/unit_tests/agent/test_chat_handler.py | 1 + .../agent/test_event_first_protocol.py | 6 ++ .../agent/test_event_log_transcript.py | 54 ++++++----- .../agent/test_orchestrator_integration.py | 7 ++ tests/unit_tests/agent/test_registry.py | 6 ++ .../agent/test_result_normalizer.py | 1 + .../agent/test_run_ledger_api_auth.py | 59 +++++++++--- tests/unit_tests/agent/test_state_store.py | 19 ++++ tests/unit_tests/plugin/conftest.py | 38 ++++++++ .../unit_tests/plugin/test_connector_pure.py | 1 + .../plugin/test_connector_reconcile.py | 43 +++++++-- tests/unit_tests/plugin/test_extract_deps.py | 1 + .../unit_tests/plugin/test_handler_actions.py | 45 ++++++++- .../unit_tests/plugin/test_handler_helpers.py | 92 ++++++++++++------- 14 files changed, 292 insertions(+), 81 deletions(-) create mode 100644 tests/unit_tests/plugin/conftest.py 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..83dbc1cb0 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.""" @@ -355,6 +359,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_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 992bbcdfd..e75f67739 100644 --- a/tests/unit_tests/agent/test_run_ledger_api_auth.py +++ b/tests/unit_tests/agent/test_run_ledger_api_auth.py @@ -173,6 +173,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}) @@ -262,6 +278,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, @@ -349,10 +366,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 @@ -1046,11 +1065,12 @@ async def test_run_append_result_basic_path(session_registry, db_engine): @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( { @@ -1066,14 +1086,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( { @@ -1089,14 +1111,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( { @@ -1112,10 +1136,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}, @@ -1139,6 +1166,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( { @@ -1152,6 +1180,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', @@ -1162,6 +1193,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', @@ -1187,10 +1221,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' @@ -1425,7 +1455,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}, @@ -1447,6 +1477,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( { @@ -1457,6 +1488,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', @@ -1467,14 +1501,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}, @@ -1503,6 +1536,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( { @@ -1517,3 +1551,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/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..3d8a55be9 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 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'} From f7c4194b3dc950219c249e16fd351b6e91969eb1 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:42:39 +0800 Subject: [PATCH 20/38] test(web): verify resource writes and recovery through behavior --- web/tests/e2e/crud-smoke.spec.ts | 165 +++++++++++++++++- web/tests/e2e/entity-loading.spec.ts | 29 ++- web/tests/e2e/home-smoke.spec.ts | 34 ++++ web/tests/e2e/pipeline-migration.spec.ts | 4 + .../e2e/processor-detail-workbench.spec.ts | 20 +++ web/tests/e2e/workspace-assistant.spec.ts | 22 ++- .../unit/processor-detail-workbench.test.mjs | 114 +++--------- .../unit/token-monitoring-error.test.mjs | 10 -- 8 files changed, 286 insertions(+), 112 deletions(-) diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index ba0d3b176..e1865bda1 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 ({ 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/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/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/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/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/unit/processor-detail-workbench.test.mjs b/web/tests/unit/processor-detail-workbench.test.mjs index 5ff6068a9..aede7e39d 100644 --- a/web/tests/unit/processor-detail-workbench.test.mjs +++ b/web/tests/unit/processor-detail-workbench.test.mjs @@ -1,97 +1,33 @@ 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'; -const testDir = path.dirname(fileURLToPath(import.meta.url)); -const webRoot = path.resolve(testDir, '../..'); - -function readSource(relativePath) { - return fs.readFileSync(path.join(webRoot, relativePath), 'utf8'); -} - -test('agent and pipeline details share the split processor workbench', () => { - const workbench = readSource( - 'src/app/home/components/processor-detail/ProcessorDetailWorkbench.tsx', - ); - const agentDetail = readSource('src/app/home/agents/AgentDetailContent.tsx'); - const pipelineDetail = readSource( - 'src/app/home/pipelines/PipelineDetailContent.tsx', - ); - const websocketClient = readSource( - 'src/app/infra/websocket/WebSocketClient.ts', - ); - const pipelineDebug = readSource( - 'src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx', - ); +const source = fs.readFileSync( + new URL( + '../../src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx', + import.meta.url, + ), + 'utf8', +); +// Shared WebSocket tests do not mount this component's socket replacement effect. +test('pipeline debug retains ownership guards before callback and connection-error updates', () => { + for (const callback of [ + 'onConnected', + 'onMessage', + 'onError', + 'onClose', + 'onBroadcast', + ]) { + assert.match( + source, + new RegExp( + `\\.${callback}\\(\\([^)]*\\) => \\{\\s*if \\(wsClientRef\\.current !== wsClient\\) return;`, + ), + ); + } assert.match( - workbench, - /lg:grid-cols-\[minmax\(20rem,0\.72fr\)_minmax\(0,1\.28fr\)\]/, + source, + /await wsClient\.connect\(\);\s*\} catch \(error\) \{\s*if \(!wsClient \|\| wsClientRef\.current !== wsClient\) return;/, ); - assert.ok( - workbench.indexOf('{debugContent}') < - workbench.lastIndexOf('{configPanel}'), - ); - assert.match(agentDetail, / \{\s*this\.reconnectAttempts = 0/, - ); - assert.match( - websocketClient, - /data\.type === 'connected'[\s\S]*this\.reconnectAttempts = 0/, - ); - assert.match(websocketClient, /private reconnectTimeout:/); - assert.match(websocketClient, /private disconnectedByUser = false/); - assert.match( - websocketClient, - /if \(!this\.connectionId\)[\s\S]*this\.shouldReconnect = false/, - ); - assert.match( - pipelineDebug, - /wsClientRef\.current = wsClient;[\s\S]*await wsClient\.connect\(\)/, - ); - assert.match( - pipelineDebug, - /if \(wsClientRef\.current !== wsClient\) return;/, - ); - assert.match(pipelineDebug, /data-slot="scroll-area-viewport"/); - assert.doesNotMatch(pipelineDebug, /scrollIntoView/); -}); - -test('processor forms expose their primary orchestration flow horizontally', () => { - const agentForm = readSource( - 'src/app/home/agents/components/AgentFormComponent.tsx', - ); - const pipelineForm = readSource( - 'src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx', - ); - - const agentSections = agentForm.slice( - agentForm.indexOf('const primarySections:'), - agentForm.indexOf('const runnerStatus ='), - ); - assert.deepEqual( - [...agentSections.matchAll(/name: '([^']+)'/g)].map((match) => match[1]), - ['runner', 'events_and_tools'], - ); - const runnerPanel = agentForm.slice( - agentForm.indexOf("{activeSection === 'runner' &&"), - agentForm.indexOf("{activeSection === 'events_and_tools' &&"), - ); - assert.match(runnerPanel, /renderDynamicStage\(runnerSelectorStage\)/); - assert.match(runnerPanel, /renderDynamicStage\(activeRunnerStage\)/); - assert.match( - pipelineForm, - /const primarySectionNames = \['trigger', 'ai', 'output'\]/, - ); - assert.match(agentForm, /]*grid-cols-2/); - assert.match(pipelineForm, /]*grid-cols-3/); - assert.doesNotMatch(agentForm, /
    { assert.equal(getErrorMessage(new Error('Network failed')), 'Network failed'); assert.equal(getErrorMessage('Request failed'), 'Request failed'); }); - -test('token monitoring uses the structured API error helper', () => { - const source = fs.readFileSync(componentPath, 'utf8'); - assert.match(source, /import \{ getErrorMessage \} from '\.\.\/utils';/); - assert.match(source, /setError\(getErrorMessage\(e\)\)/); -}); From d5bc0094263c55d0fed4d5509d1623952354cd85 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:42:54 +0800 Subject: [PATCH 21/38] test(api): verify extension persistence and connection task behavior --- .../api/service/test_mcp_service.py | 122 +++++++++++++++--- .../api/service/test_webhook_service.py | 47 ++++++- 2 files changed, 146 insertions(+), 23 deletions(-) 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_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: From 1adc2a60468e8ce74d3a5c0bcc62658a3c202d28 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:43:38 +0800 Subject: [PATCH 22/38] test(api): verify orchestration writes and scoped state transitions --- .../api/service/test_bot_service.py | 55 ++++++++--- .../api/service/test_pipeline_migration.py | 13 ++- .../api/service/test_pipeline_service.py | 96 +++++++++++++++---- .../api/service/test_session_context_reset.py | 20 ++-- .../api/test_adapter_session_scoping.py | 9 +- 5 files changed, 151 insertions(+), 42 deletions(-) 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_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_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_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/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() From bac69e987a9c17ac4fd9eba5667436f430c39871 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:43:50 +0800 Subject: [PATCH 23/38] test(api): verify identity predicates and credential side effects --- .../api/service/test_space_service.py | 27 +++++- .../api/service/test_user_service.py | 90 +++++++++++++++++-- tests/unit_tests/api/test_apikey_service.py | 25 +++++- .../api/test_user_reset_password.py | 11 ++- 4 files changed, 142 insertions(+), 11 deletions(-) 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/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_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(): From 5ad95e100e819671201771dfdc58c516718717fa Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:44:19 +0800 Subject: [PATCH 24/38] test(api): verify maintenance outcomes and upload context --- .../api/service/test_maintenance_service.py | 133 +++++++++++------- .../api/test_file_upload_scoping.py | 8 ++ 2 files changed, 93 insertions(+), 48 deletions(-) diff --git a/tests/unit_tests/api/service/test_maintenance_service.py b/tests/unit_tests/api/service/test_maintenance_service.py index 16f27aa38..c43ecbff5 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']: (section['path'], 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.""" @@ -463,8 +493,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: @@ -485,12 +519,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.""" @@ -507,7 +538,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: @@ -574,7 +607,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: @@ -789,11 +822,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.""" @@ -908,7 +947,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: @@ -929,40 +968,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.""" @@ -992,7 +1029,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/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' From 1c0fd7d0ba7219875c63df01256084494cbcea2a Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:45:09 +0800 Subject: [PATCH 25/38] test: strengthen startup and persistence boundary oracles --- .../persistence/test_codex_auth.py | 5 +- .../persistence/test_workspace_migration.py | 80 +++++++------------ tests/unit_tests/core/test_boot.py | 4 + tests/unit_tests/core/test_bootutils_log.py | 8 +- tests/unit_tests/core/test_stage.py | 4 +- .../test_bot_plugin_processor_migration.py | 25 ++++++ .../persistence/test_mgr_methods.py | 4 +- .../test_runner_state_migration.py | 2 + .../persistence/test_serialize_model.py | 2 +- 9 files changed, 73 insertions(+), 61 deletions(-) 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_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/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_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_stage.py b/tests/unit_tests/core/test_stage.py index e9b29e76b..0d008451c 100644 --- a/tests/unit_tests/core/test_stage.py +++ b/tests/unit_tests/core/test_stage.py @@ -41,11 +41,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): 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_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): From cbc049400c96ad422c8f3d59a4cb2354846bad08 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:53:00 +0800 Subject: [PATCH 26/38] test(api): verify bot response and service delegation contracts --- tests/integration/api/test_bots.py | 70 +++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 7 deletions(-) diff --git a/tests/integration/api/test_bots.py b/tests/integration/api/test_bots.py index 3e3a3a871..726a9c058 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).""" @@ -194,7 +199,7 @@ class TestBotEndpoints: """Tests for /api/v1/platform/bots endpoints.""" @pytest.mark.asyncio - async def test_get_bots_success(self, quart_test_client): + async def test_get_bots_success(self, quart_test_client, fake_bot_app): """GET /api/v1/platform/bots returns bot list.""" response = await quart_test_client.get('/api/v1/platform/bots', headers={'Authorization': 'Bearer test_token'}) @@ -204,8 +209,17 @@ class TestBotEndpoints: assert 'data' in data assert 'bots' in data['data'] + called = fake_bot_app.bot_service.get_bots + 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']['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 +232,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 +253,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 +279,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 +294,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 +321,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 From 4a2780b84185196d047de911bdebddc8a50177c3 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:53:26 +0800 Subject: [PATCH 27/38] test(vector): verify real Valkey search and deletion results --- .../integration/vector/test_valkey_search.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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') From c03f33d8555afb7d5260070d6d97856a6ca75d29 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:57:15 +0800 Subject: [PATCH 28/38] test(runtime): verify sandbox and platform contracts --- tests/e2e/test_startup.py | 155 ++++++++++++++---- tests/integration/pipeline/test_full_flow.py | 21 ++- .../box/test_box_integration.py | 69 +++++++- tests/unit_tests/box/test_box_service.py | 108 +++++++----- .../test_officialaccount_eba_adapter.py | 5 +- .../platform/test_openclaw_weixin_client.py | 16 +- .../platform/test_slack_eba_adapter.py | 7 +- .../platform/test_telegram_eba_adapter.py | 11 ++ .../test_websocket_adapter_attachments.py | 16 +- 9 files changed, 305 insertions(+), 103 deletions(-) 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/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_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/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 1d680ba7a..3f6da759b 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: @@ -1973,13 +1999,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 +2036,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 +2048,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 +2062,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): @@ -2172,6 +2202,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 +2216,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/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 From 9b350132f4224a1173e1aa505a40bc852115958b Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:57:35 +0800 Subject: [PATCH 29/38] test(api): cover send delegation and reuse the prior list contract --- tests/integration/api/test_bots.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/tests/integration/api/test_bots.py b/tests/integration/api/test_bots.py index 726a9c058..0f332bac5 100644 --- a/tests/integration/api/test_bots.py +++ b/tests/integration/api/test_bots.py @@ -199,7 +199,7 @@ class TestBotEndpoints: """Tests for /api/v1/platform/bots endpoints.""" @pytest.mark.asyncio - async def test_get_bots_success(self, quart_test_client, fake_bot_app): + async def test_get_bots_success(self, quart_test_client): """GET /api/v1/platform/bots returns bot list.""" response = await quart_test_client.get('/api/v1/platform/bots', headers={'Authorization': 'Bearer test_token'}) @@ -209,15 +209,6 @@ class TestBotEndpoints: assert 'data' in data assert 'bots' in data['data'] - called = fake_bot_app.bot_service.get_bots - 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']['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, fake_bot_app): """POST /api/v1/platform/bots creates new bot.""" @@ -422,7 +413,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', @@ -439,6 +430,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.""" From 753e3cf0702fdae8b4f79712f3375836cfda4cd5 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:58:12 +0800 Subject: [PATCH 30/38] test(web): wait for filter popups to restore focus --- web/tests/e2e/monitoring-executions.spec.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/web/tests/e2e/monitoring-executions.spec.ts b/web/tests/e2e/monitoring-executions.spec.ts index 6e253bad6..8d33cbf13 100644 --- a/web/tests/e2e/monitoring-executions.spec.ts +++ b/web/tests/e2e/monitoring-executions.spec.ts @@ -445,9 +445,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) { From 6ea7fb9fc83207ebcbc6bbfca64b4dea3e229a13 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:07:00 +0800 Subject: [PATCH 31/38] test(discord): assert attachment and guild delivery targets --- .../platform/test_botmgr_execution_trace.py | 1 + .../platform/test_discord_eba_adapter.py | 25 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) 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() From 0f522e5286b2c318c22642af9cd4848852cee257 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:05:12 +0800 Subject: [PATCH 32/38] test(persistence): verify migration data and startup task bounds --- .../test_migration_branch_convergence.py | 75 +++++++- .../persistence/test_migrations.py | 178 +----------------- .../persistence/test_migrations_postgres.py | 84 +++------ 3 files changed, 108 insertions(+), 229 deletions(-) diff --git a/tests/integration/persistence/test_migration_branch_convergence.py b/tests/integration/persistence/test_migration_branch_convergence.py index 6020c9b37..9c47ad96f 100644 --- a/tests/integration/persistence/test_migration_branch_convergence.py +++ b/tests/integration/persistence/test_migration_branch_convergence.py @@ -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,18 @@ 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), + ], +) +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 +385,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') @@ -351,8 +401,29 @@ async def test_empty_database_startup_schema_then_real_migrations(convergence_en 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' def _legacy_shared_certification(**overrides): diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index 9edfd5abb..6363854d2 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: diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index 1c0eb95c7..e6601c65c 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( @@ -1070,7 +1014,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 +1075,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 From 8e447dae095be31179672bb8d88d8ca68dd9cafe Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:09:23 +0800 Subject: [PATCH 33/38] test(web): exercise marketplace installs and wizard contracts --- web/tests/e2e/crud-smoke.spec.ts | 524 ++++++++++-------- .../e2e/knowledge-engine-marketplace.spec.ts | 148 +++++ web/tests/e2e/marketplace-likes.spec.ts | 89 +++ web/tests/e2e/plugin-upgrade-recovery.spec.ts | 142 +++++ .../e2e/wizard-platform-regressions.spec.ts | 351 +++++++----- .../knowledge-engine-marketplace.test.mjs | 384 ++++++++----- web/tests/unit/marketplace-likes.test.mjs | 217 +++++--- web/tests/unit/wizard-page-bot.test.mjs | 230 ++++---- 8 files changed, 1400 insertions(+), 685 deletions(-) create mode 100644 web/tests/e2e/knowledge-engine-marketplace.spec.ts create mode 100644 web/tests/e2e/marketplace-likes.spec.ts create mode 100644 web/tests/e2e/plugin-upgrade-recovery.spec.ts diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index ba0d3b176..60eccb8eb 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -865,253 +865,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, 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/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/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/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/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/wizard-page-bot.test.mjs b/web/tests/unit/wizard-page-bot.test.mjs index aea775a7f..08ac81a86 100644 --- a/web/tests/unit/wizard-page-bot.test.mjs +++ b/web/tests/unit/wizard-page-bot.test.mjs @@ -1,126 +1,120 @@ 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 wizardSource = fs.readFileSync( - path.resolve(currentDirectory, '../../src/app/wizard/page.tsx'), - 'utf8', -); -const runnerMarketplaceSource = fs.readFileSync( - path.resolve( - currentDirectory, - '../../src/app/home/agents/runner-marketplace.ts', - ), - 'utf8', -); -const widgetSource = fs.readFileSync( - path.resolve( - currentDirectory, - '../../../src/langbot/templates/embed/widget.js', - ), - 'utf8', -); +function load(source, deps = {}, globals = {}) { + const code = ts.transpileModule( + fs.readFileSync(new URL('../../src/' + source, import.meta.url), 'utf8'), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }, + ).outputText; + const mod = { exports: {} }; + new Function('require', 'module', 'exports', ...Object.keys(globals), code)( + (id) => { + assert.ok(id in deps, id); + return deps[id]; + }, + mod, + mod.exports, + ...Object.values(globals), + ); + return mod.exports; +} -test('shows the test-only notice only when the wizard opts in', () => { - assert.match( - wizardSource, - /widget\.js\?preview=wizard&v=\$\{Date\.now\(\)\}/, +test('wizard Runner recovery waits for the matching plugin registration without installing twice', async () => { + const scope = 'wizard-ai-runner'; + const pending = { + scope, + taskId: 12, + pluginId: 'qa/runner', + pluginAuthor: 'qa', + pluginName: 'runner', + pluginLabel: 'QA Runner', + startedAt: -1000000, + }; + const storage = new Map([ + [`langbot-runner-install:alpha:${scope}`, JSON.stringify(pending)], + ]); + const calls = [], + progress = []; + let workspace = 'beta', + tick = 0, + polls = 0; + const option = { + name: 'plugin:qa/runner/default', + label: { en_US: 'Registered Runner' }, + }; + const configTab = { + name: 'ai', + stages: [ + { + name: 'runner', + config: [ + { + name: 'id', + options: [{ name: 'plugin:qa/runner-neighbor/default' }], + }, + ], + }, + ], + }; + const httpClient = { + getAsyncTask: async (id) => { + calls.push(['task', id]); + return { runtime: { done: ++polls >= 2 } }; + }, + getGeneralPipelineMetadata: async () => { + calls.push(['metadata']); + if (calls.filter((value) => value[0] === 'metadata').length === 2) + configTab.stages[0].config[0].options.push(option); + return { configs: [configTab] }; + }, + installPluginFromMarketplace: async () => + assert.fail('recovery must not reinstall'), + }; + const api = load( + 'app/home/agents/runner-marketplace.ts', + { + '@/app/infra/http/HttpClient': { httpClient }, + '@/app/infra/http': {}, + '@/app/infra/http/workspaceContext': { + getActiveWorkspaceUuid: () => workspace, + }, + '@/app/infra/entities/plugin': {}, + }, + { + window: new EventTarget(), + CustomEvent, + sessionStorage: { + getItem: (key) => storage.get(key) ?? null, + removeItem: (key) => storage.delete(key), + }, + Date: { now: () => tick }, + setTimeout: (callback, ms) => { + tick += ms; + queueMicrotask(callback); + }, + }, ); - assert.match(wizardSource, /script\.dataset\.testNotice = testNotice/); - assert.match( - wizardSource, - /testNotice=\{t\('wizard\.botConfig\.pageBotTestNotice'\)\}/, + assert.equal(await api.resumePendingRunnerInstall(scope), null); + workspace = 'alpha'; + assert.equal(await api.resumePendingRunnerInstall('other'), null); + const recovered = await api.resumePendingRunnerInstall(scope, (task) => + progress.push(task.runtime.done), ); - assert.match(widgetSource, /getAttribute\("data-test-notice"\)/); - assert.match(widgetSource, /if \(scriptTestNotice\)/); - assert.match(widgetSource, /testNotice\.textContent = scriptTestNotice/); -}); - -test('opens the Page Bot preview after every successful wizard save', () => { - assert.match(wizardSource, /script\.dataset\.autoOpen = 'true'/); - assert.match( - wizardSource, - /setPageBotPreviewRequest\(\(request\) => request \+ 1\)/, - ); - - assert.match(widgetSource, /getAttribute\("data-auto-open"\) === "true"/); - assert.match(widgetSource, /root\.langbotOpen = function \(\)/); - assert.match(widgetSource, /if \(scriptAutoOpen\) root\.langbotOpen\(\)/); -}); - -test('binds every wizard bot to its provisional pipeline before verification', () => { - assert.match( - wizardSource, - /if \(!previewPipelineUuid\) \{[\s\S]*?httpClient\.createPipeline\(/, - ); - assert.match( - wizardSource, - /event_pattern: 'message\.received',[\s\S]*?target_type: 'pipeline',[\s\S]*?target_uuid: previewPipelineUuid/, - ); - assert.doesNotMatch( - wizardSource, - /selectedAdapter === 'web_page_bot' && !previewPipelineUuid/, - ); -}); - -test('keeps the 4.11 Runner marketplace installation flow', () => { - assert.match(wizardSource, /loadRunnerCatalog\(\)/); - assert.match( - wizardSource, - /installMarketplaceRunner\(plugin, \{[\s\S]*?scope: WIZARD_RUNNER_INSTALL_SCOPE/, - ); - assert.match(wizardSource, /resumePendingRunnerInstall\(/); - assert.match(runnerMarketplaceSource, /RUNNER_COMPONENT_FILTER = 'Runner'/); - assert.match(runnerMarketplaceSource, /installPluginFromMarketplace\(/); - assert.match( - runnerMarketplaceSource, - /const prefix = runnerPluginPrefix\(\{/, - ); - assert.match(runnerMarketplaceSource, /option\.name\.startsWith\(prefix\)/); - assert.match(runnerMarketplaceSource, /registrationDeadline/); - assert.match(runnerMarketplaceSource, /sessionStorage\.setItem\(/); -}); - -test('requires the selected Runner mandatory configuration before finishing', () => { - assert.match( - wizardSource, - /isRequiredRunnerConfigComplete\(selectedRunnerConfigItems, runnerConfig\)/, - ); - assert.match(wizardSource, /aiChoice === 'external'/); - assert.match( - wizardSource, - /!createdBotUuid \|\| !createdPipelineUuid \|\| !canProceed\(\)/, - ); -}); - -test('requires an observed message before configuring the runner', () => { - assert.match( - wizardSource, - /createdBotUuid !== null && botSaved && messageReceived/, - ); - assert.match(wizardSource, /requiresMessageVerification/); - assert.match(wizardSource, /onMessageReceived=\{handleMessageReceived\}/); -}); - -test('warns local-account users after the bot receives an IM message', () => { - assert.match( - wizardSource, - /messageReceived && userInfo\?\.account_type !== 'space'/, - ); - assert.match( - wizardSource, - /wizard\.botConfig\.messageReceivedLocalAccountWarning/, - ); - assert.match(wizardSource, / { - assert.match( - wizardSource, - /testHttpBotInbound\(createdBotUuid, testMessage\.trim\(\)\)/, - ); - assert.match(wizardSource, /wizard\.botConfig\.sendHttpTest/); + assert.equal(recovered.runner, option); + assert.equal(recovered.configTab, configTab); + assert.deepEqual(progress, [false, true]); + assert.deepEqual(calls, [ + ['task', 12], + ['task', 12], + ['metadata'], + ['metadata'], + ]); + assert.equal(storage.size, 0); }); From 30f9111e3f4b9b369ac1bb9c0371f64dfac1242d Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:58:12 +0800 Subject: [PATCH 34/38] test(web): wait for filter popups to restore focus --- web/tests/e2e/monitoring-executions.spec.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/web/tests/e2e/monitoring-executions.spec.ts b/web/tests/e2e/monitoring-executions.spec.ts index 6e253bad6..8d33cbf13 100644 --- a/web/tests/e2e/monitoring-executions.spec.ts +++ b/web/tests/e2e/monitoring-executions.spec.ts @@ -445,9 +445,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) { From 0f230148e3c28e90460fd9a57176c5dd44a7962d Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:19:25 +0800 Subject: [PATCH 35/38] test(web): cover delayed native debug socket cancellation --- web/tests/e2e/debug-socket-ownership.spec.ts | 176 +++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 web/tests/e2e/debug-socket-ownership.spec.ts 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())); + } +}); From c14bc0a45a8e54ecd324487081eeb75913ce51ac Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:38:21 +0800 Subject: [PATCH 36/38] test(web): replace Workspace source checks with runtime assertions --- web/tests/e2e/qr-code-workspace.spec.ts | 118 ++++++++++ web/tests/e2e/space-launch.spec.ts | 53 +++++ web/tests/e2e/workspace-layout.spec.ts | 213 ++++++++++++++++++ web/tests/e2e/workspace-member-email.spec.ts | 5 + .../unit/qr-code-login-workspace.test.mjs | 58 ++--- web/tests/unit/space-launch-callback.test.mjs | 24 -- .../unit/workspace-member-email.test.mjs | 24 -- .../unit/workspace-switcher-layout.test.mjs | 88 -------- 8 files changed, 412 insertions(+), 171 deletions(-) create mode 100644 web/tests/e2e/qr-code-workspace.spec.ts create mode 100644 web/tests/e2e/space-launch.spec.ts create mode 100644 web/tests/e2e/workspace-layout.spec.ts delete mode 100644 web/tests/unit/space-launch-callback.test.mjs delete mode 100644 web/tests/unit/workspace-member-email.test.mjs 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/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/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/unit/qr-code-login-workspace.test.mjs b/web/tests/unit/qr-code-login-workspace.test.mjs index 32e890e49..4b1a6f2d5 100644 --- a/web/tests/unit/qr-code-login-workspace.test.mjs +++ b/web/tests/unit/qr-code-login-workspace.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; +import ts from 'typescript'; const root = process.cwd(); const dialogPath = path.join( @@ -12,43 +13,30 @@ const localeDir = path.join(root, 'src/i18n/locales'); const backendUrlPath = path.join(root, 'src/app/infra/http/backendUrl.ts'); const dialogSource = fs.readFileSync(dialogPath, 'utf8'); -const backendUrlSource = fs.readFileSync(backendUrlPath, 'utf8'); +const { resolveBackendBaseUrl } = await import( + `data:text/javascript;base64,${Buffer.from( + ts.transpileModule(fs.readFileSync(backendUrlPath, 'utf8'), { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + }, + }).outputText, + ).toString('base64')}` +); test('QR login resolves a proxy-root API base against the current origin', () => { - assert.match(dialogSource, /const baseUrl = getBackendBaseUrl\(\)/); - assert.match(backendUrlSource, /!configured \|\| configured === '\/'/); - assert.match(backendUrlSource, /return normalizedOrigin/); - assert.doesNotMatch( - dialogSource, - /import\.meta\.env\.VITE_API_BASE_URL \|\| window\.location\.origin/, - ); -}); - -test('QR credential exchanges preserve the active Workspace scope', () => { - assert.match(dialogSource, /getActiveWorkspaceUuid/); - assert.match( - dialogSource, - /sessionWorkspaceUuidRef\.current = workspaceUuid/, - ); - assert.match( - dialogSource, - /const workspaceUuid = sessionWorkspaceUuidRef\.current/, - ); - assert.match(dialogSource, /sessionApiBaseRef\.current = cfg\.apiBase/); - assert.match( - dialogSource, - /`\$\{baseUrlRef\.current\}\$\{sessionApiBaseRef\.current\}\/\$\{sessionIdRef\.current\}`/, - ); - assert.match(dialogSource, /'X-Workspace-Id': workspaceUuid/); - - const workspaceHeaderUses = dialogSource.match( - /'X-Workspace-Id': workspaceUuid/g, - ); - assert.equal( - workspaceHeaderUses?.length, - 4, - 'start, poll, expiry cleanup, and dialog cleanup must all retain Workspace scope', - ); + for (const [configured, expected] of [ + [undefined, 'https://core.example'], + ['', 'https://core.example'], + ['/', 'https://core.example'], + ['/proxy/', 'https://core.example/proxy'], + ['proxy', 'https://core.example/proxy'], + ['https://api.example/root/', 'https://api.example/root'], + ]) + assert.equal( + resolveBackendBaseUrl(configured, 'https://core.example///'), + expected, + ); }); test('WeChat QR login never reuses Feishu progress copy', () => { diff --git a/web/tests/unit/space-launch-callback.test.mjs b/web/tests/unit/space-launch-callback.test.mjs deleted file mode 100644 index 7debabf6c..000000000 --- a/web/tests/unit/space-launch-callback.test.mjs +++ /dev/null @@ -1,24 +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/auth/space/callback/page.tsx', import.meta.url), - 'utf8', -); - -test('direct launch assertion is fragment-only and removed before exchange', () => { - assert.doesNotMatch(source, /searchParams\.get\(['"]launch_assertion['"]\)/); - const readIndex = source.indexOf("fragmentParams.get('launch_assertion')"); - const clearIndex = source.indexOf('window.history.replaceState'); - const exchangeIndex = source.indexOf('handleOAuthCallback(', clearIndex); - assert.ok(readIndex >= 0, 'fragment assertion read is missing'); - assert.ok( - clearIndex > readIndex, - 'URL fragment is not cleared after copying the assertion', - ); - assert.ok( - exchangeIndex > clearIndex, - 'assertion exchange starts before the fragment is cleared', - ); -}); diff --git a/web/tests/unit/workspace-member-email.test.mjs b/web/tests/unit/workspace-member-email.test.mjs deleted file mode 100644 index 977adb0de..000000000 --- a/web/tests/unit/workspace-member-email.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -const panelSource = await readFile( - new URL( - '../../src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx', - import.meta.url, - ), - 'utf8', -); -const entitySource = await readFile( - new URL('../../src/app/infra/entities/workspace.ts', import.meta.url), - 'utf8', -); - -test('workspace member rows show display name, email, and role', () => { - assert.match(entitySource, /display_name:\s*string/); - assert.match(panelSource, /\{member\.display_name\}/); - assert.match( - panelSource, - /]*>[\s\S]*?\{member\.email\}[\s\S]*?workspace\.roles\.\$\{member\.role\}[\s\S]*?<\/ItemDescription>/, - ); -}); diff --git a/web/tests/unit/workspace-switcher-layout.test.mjs b/web/tests/unit/workspace-switcher-layout.test.mjs index 09e63aefa..8be7004d5 100644 --- a/web/tests/unit/workspace-switcher-layout.test.mjs +++ b/web/tests/unit/workspace-switcher-layout.test.mjs @@ -18,103 +18,15 @@ const homeSidebarSource = readSource( const workspaceSettingsPanelSource = readSource( 'src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx', ); -const workspaceSwitcherSource = readSource( - 'src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx', -); - test('renders WorkspaceSwitcher only from HomeSidebar', () => { assert.doesNotMatch(homeLayoutSource, / { - const headerEnd = homeSidebarSource.indexOf(''); - const switcher = homeSidebarSource.indexOf('{t('sidebar.home')}", - ); - - assert.notEqual(headerEnd, -1); - assert.notEqual(switcher, -1); - assert.notEqual(contentStart, -1); - assert.notEqual(homeGroup, -1); - assert.ok( - headerEnd < switcher, - 'WorkspaceSwitcher must follow SidebarHeader', - ); - assert.ok( - switcher < contentStart && switcher < homeGroup, - 'WorkspaceSwitcher must precede SidebarContent and the Home group', - ); -}); - -test('hides WorkspaceSwitcher for the singleton local OSS workspace and keeps it for Cloud or multiple workspaces', () => { - assert.match( - workspaceSwitcherSource, - /if \(!currentWorkspace\) return null;/, - ); - assert.match( - homeSidebarSource, - /const workspaces = useWorkspaceBootstrap\(\);/, - ); - assert.match( - homeSidebarSource, - /const showWorkspaceSwitcher\s*=\s*workspaces\.length\s*>\s*1\s*\|\|\s*currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'/, - ); - assert.match( - homeSidebarSource, - /\{showWorkspaceSwitcher\s*&&\s*\(\s*
    { - assert.match(workspaceSettingsPanelSource, /cloudPlans\.title/); - assert.match(workspaceSettingsPanelSource, / { - assert.match( - homeSidebarSource, - /
    ]*truncate/); -}); - -test('uses an infrastructure-level Box health endpoint in monitoring', () => { - const statusCardSource = readSource( - 'src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx', - ); - const backendClientSource = readSource('src/app/infra/http/BackendClient.ts'); - assert.match(backendClientSource, /getBoxRuntimeStatus/); - assert.match(statusCardSource, /getBoxRuntimeStatus/); -}); From 8d4117aae0a6f9837a16a0b29f2b7ace1ec739f8 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:54:46 +0800 Subject: [PATCH 37/38] test(web): resolve clipboard fixture dependencies through Vite --- web/tests/e2e/codex-copy-feedback.spec.ts | 17 +------ web/tests/e2e/fixtures/codex-copy-harness.mjs | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 16 deletions(-) create mode 100644 web/tests/e2e/fixtures/codex-copy-harness.mjs 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/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(); From 794b01828175b08b4af04531a4a2ec3b64774051 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:02:23 +0800 Subject: [PATCH 38/38] fix(web): keep provider names visible on narrow cards --- .../home/components/models-dialog/components/ProviderCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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}