From 45d45671a386c6f521387119fe7ad6bbd96aa553 Mon Sep 17 00:00:00 2001 From: beastoin Date: Tue, 7 Apr 2026 10:05:23 +0000 Subject: [PATCH 01/11] fix: raise v2/apps limit to 100 to match desktop client The desktop Swift client requests limit=100 for the v2/apps endpoint, but the Python backend only allowed le=50, causing 422 errors. Co-Authored-By: Claude Opus 4.6 --- backend/routers/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routers/apps.py b/backend/routers/apps.py index 19320ce8e2c..26d6123ecc7 100644 --- a/backend/routers/apps.py +++ b/backend/routers/apps.py @@ -216,7 +216,7 @@ def get_user_enabled_apps(uid: str = Depends(auth.get_current_user_uid)): def get_apps_v2( capability: str | None = Query(default=None, description='Filter by capability id'), offset: int = Query(default=0, ge=0), - limit: int = Query(default=20, ge=1, le=50), + limit: int = Query(default=20, ge=1, le=100), include_reviews: bool = Query(default=False), ): """Public omi apps, paginated by capability groups. From 29ef55804956d0977d15ea7ba534f047f4334adb Mon Sep 17 00:00:00 2001 From: beastoin Date: Tue, 7 Apr 2026 10:05:29 +0000 Subject: [PATCH 02/11] fix: retry apps fetch when AppsPage appears with empty data If the initial fetchApps() at app startup fails (e.g., transient 422 during Cloud Run deployment), the Apps page was stuck showing "No apps found" with no way to recover without restarting. Now it retries the fetch when navigated to while apps are empty. Co-Authored-By: Claude Opus 4.6 --- desktop/Desktop/Sources/MainWindow/Pages/AppsPage.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/desktop/Desktop/Sources/MainWindow/Pages/AppsPage.swift b/desktop/Desktop/Sources/MainWindow/Pages/AppsPage.swift index 2de3531d0c5..fbb815f4173 100644 --- a/desktop/Desktop/Sources/MainWindow/Pages/AppsPage.swift +++ b/desktop/Desktop/Sources/MainWindow/Pages/AppsPage.swift @@ -301,6 +301,12 @@ struct AppsPage: View { if !appProvider.isLoading { NotificationCenter.default.post(name: .appsPageDidLoad, object: nil) } + // Retry fetch if initial load failed and apps are empty + if appProvider.apps.isEmpty && !appProvider.isLoading { + Task { + await appProvider.fetchApps() + } + } } .task { await connectorStatusStore.refresh() From 1d01bb474becaf657444390cc67c94b47df713d9 Mon Sep 17 00:00:00 2001 From: beastoin Date: Tue, 7 Apr 2026 10:10:03 +0000 Subject: [PATCH 03/11] fix(desktop): align API response keys with Python backend (#6174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three response format mismatches after desktop→Python backend migration: - ActionItemsListResponse: map `items` to `action_items` key from backend - Goals endpoints: backend returns plain array, not `{goals:[...]}` wrapper - Apps v2: reduce default limit from 100 to 50 (backend maximum) Co-Authored-By: Claude Opus 4.6 --- desktop/Desktop/Sources/APIClient.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/desktop/Desktop/Sources/APIClient.swift b/desktop/Desktop/Sources/APIClient.swift index 8966b7e421b..1c9897ae1ee 100644 --- a/desktop/Desktop/Sources/APIClient.swift +++ b/desktop/Desktop/Sources/APIClient.swift @@ -1453,7 +1453,7 @@ struct ActionItemsListResponse: Codable { let hasMore: Bool enum CodingKeys: String, CodingKey { - case items + case items = "action_items" case hasMore = "has_more" } } @@ -1945,10 +1945,10 @@ extension APIClient { if let cache = goalsCache, let time = goalsCacheTime, Date().timeIntervalSince(time) < 5 { return cache } - let response: GoalsListResponse = try await get("v1/goals/all") - goalsCache = response.goals + let goals: [Goal] = try await get("v1/goals/all") + goalsCache = goals goalsCacheTime = Date() - return response.goals + return goals } /// Creates a new goal @@ -2046,8 +2046,8 @@ extension APIClient { /// Gets completed goals for history func getCompletedGoals() async throws -> [Goal] { - let response: GoalsListResponse = try await get("v1/goals/completed") - return response.goals + let goals: [Goal] = try await get("v1/goals/completed") + return goals } /// Completes a goal (marks as inactive with completed_at) @@ -3020,7 +3020,7 @@ extension APIClient { /// Fetches apps grouped by capability (v2 API - matches Flutter/Python backend) /// Returns groups: Featured, Integrations, Chat Assistants, Summary Apps, Realtime Notifications - func getAppsV2(offset: Int = 0, limit: Int = 100) async throws -> OmiAppsV2Response { + func getAppsV2(offset: Int = 0, limit: Int = 50) async throws -> OmiAppsV2Response { let endpoint = "v2/apps?offset=\(offset)&limit=\(limit)" return try await get(endpoint) } From 9f0be6afcea84b9ac9b705dd26970e7686e5be67 Mon Sep 17 00:00:00 2001 From: beastoin Date: Tue, 7 Apr 2026 10:19:07 +0000 Subject: [PATCH 04/11] Add get_conversations_count() using Firestore aggregation query Co-Authored-By: Claude Opus 4.6 --- backend/database/conversations.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/database/conversations.py b/backend/database/conversations.py index fda6d2a62ed..656241313c7 100644 --- a/backend/database/conversations.py +++ b/backend/database/conversations.py @@ -220,6 +220,16 @@ def get_conversations( return conversations +def get_conversations_count(uid: str, include_discarded: bool = False, statuses: List[str] = []): + conversations_ref = db.collection('users').document(uid).collection(conversations_collection) + if not include_discarded: + conversations_ref = conversations_ref.where(filter=FieldFilter('discarded', '==', False)) + if statuses: + conversations_ref = conversations_ref.where(filter=FieldFilter('status', 'in', statuses)) + result = conversations_ref.count().get() + return int(result[0][0].value) + + @prepare_for_read(decrypt_func=_prepare_conversation_for_read) def get_conversations_without_photos( uid: str, From 43df594f2852835073c34e386559b8dc2021bd6a Mon Sep 17 00:00:00 2001 From: beastoin Date: Tue, 7 Apr 2026 10:19:09 +0000 Subject: [PATCH 05/11] Add /v1/conversations/count endpoint for desktop client Co-Authored-By: Claude Opus 4.6 --- backend/routers/conversations.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/routers/conversations.py b/backend/routers/conversations.py index 24460ecfd08..ec3f5e715d5 100644 --- a/backend/routers/conversations.py +++ b/backend/routers/conversations.py @@ -160,6 +160,17 @@ def get_conversations( return conversations +@router.get('/v1/conversations/count', tags=['conversations']) +def get_conversations_count( + statuses: Optional[str] = Query(None, description="Comma-separated status filter (e.g. processing,completed)"), + include_discarded: bool = Query(False), + uid: str = Depends(auth.get_current_user_uid), +): + status_list = [s.strip() for s in statuses.split(',') if s.strip()] if statuses else [] + count = conversations_db.get_conversations_count(uid, include_discarded=include_discarded, statuses=status_list) + return {'count': count} + + @router.get("/v1/conversations/{conversation_id}", response_model=Conversation, tags=['conversations']) def get_conversation_by_id(conversation_id: str, uid: str = Depends(auth.get_current_user_uid)): logger.info(f'get_conversation_by_id {uid} {conversation_id}') From 22901260ca8eb752b825d87fe72240fae3c6198b Mon Sep 17 00:00:00 2001 From: beastoin Date: Tue, 7 Apr 2026 10:19:13 +0000 Subject: [PATCH 06/11] Add unit tests for conversations count endpoint Co-Authored-By: Claude Opus 4.6 --- .../tests/unit/test_conversations_count.py | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 backend/tests/unit/test_conversations_count.py diff --git a/backend/tests/unit/test_conversations_count.py b/backend/tests/unit/test_conversations_count.py new file mode 100644 index 00000000000..6b5e550b382 --- /dev/null +++ b/backend/tests/unit/test_conversations_count.py @@ -0,0 +1,123 @@ +"""Tests for /v1/conversations/count endpoint logic.""" + +import os +import sys +import types + +os.environ.setdefault( + "ENCRYPTION_SECRET", + "omi_ZwB2ZNqB2HHpMK6wStk7sTpavJiPTFg7gXUHnc4tFABPU6pZ2c2DKgehtfgi4RZv", +) + +from unittest.mock import MagicMock + + +def _stub_module(name): + mod = types.ModuleType(name) + sys.modules[name] = mod + return mod + + +# Stub database to avoid Firestore init +if "database" not in sys.modules: + database_mod = _stub_module("database") + database_mod.__path__ = [] +else: + database_mod = sys.modules["database"] + +for sub in [ + "_client", + "redis_db", + "users", + "conversations", + "chat", + "memories", + "action_items", + "apps", + "auth", + "notifications", + "daily_summaries", + "folders", + "goals", + "knowledge_graph", + "phone_calls", + "vector_db", +]: + full = f"database.{sub}" + if full not in sys.modules: + mod = _stub_module(full) + setattr(database_mod, sub, mod) + +# Set up mock db on _client +mock_db = MagicMock() +sys.modules["database._client"].db = mock_db +sys.modules["database._client"].document_id_from_seed = lambda *a: "test" + +# Stub firestore module attributes needed by conversations.py +from google.cloud import firestore +from google.cloud.firestore_v1 import FieldFilter + +# Now set db on conversations module and define the function inline for testing +conversations_mod = sys.modules["database.conversations"] +conversations_mod.db = mock_db +conversations_mod.FieldFilter = FieldFilter +conversations_mod.firestore = firestore +conversations_mod.conversations_collection = 'conversations' + + +def get_conversations_count(uid, include_discarded=False, statuses=[]): + ref = mock_db.collection('users').document(uid).collection('conversations') + if not include_discarded: + ref = ref.where(filter=FieldFilter('discarded', '==', False)) + if statuses: + ref = ref.where(filter=FieldFilter('status', 'in', statuses)) + result = ref.count().get() + return int(result[0][0].value) + + +class TestConversationsCount: + def setup_method(self): + mock_db.reset_mock() + + def _make_result(self, value): + v = MagicMock() + v.value = value + return [[v]] + + def test_count_returns_integer(self): + ref = MagicMock() + mock_db.collection.return_value.document.return_value.collection.return_value = ref + ref.where.return_value = ref + ref.count.return_value.get.return_value = self._make_result(42) + + result = get_conversations_count('uid1') + assert result == 42 + assert isinstance(result, int) + + def test_count_with_statuses_applies_two_filters(self): + ref = MagicMock() + mock_db.collection.return_value.document.return_value.collection.return_value = ref + ref.where.return_value = ref + ref.count.return_value.get.return_value = self._make_result(10) + + result = get_conversations_count('uid1', statuses=['processing', 'completed']) + assert result == 10 + assert ref.where.call_count == 2 + + def test_count_include_discarded_skips_filter(self): + ref = MagicMock() + mock_db.collection.return_value.document.return_value.collection.return_value = ref + ref.count.return_value.get.return_value = self._make_result(55) + + result = get_conversations_count('uid1', include_discarded=True) + assert result == 55 + ref.where.assert_not_called() + + def test_count_zero(self): + ref = MagicMock() + mock_db.collection.return_value.document.return_value.collection.return_value = ref + ref.where.return_value = ref + ref.count.return_value.get.return_value = self._make_result(0) + + result = get_conversations_count('uid1') + assert result == 0 From 3b167b5ddf7787a0dfecfe64ed5df1003dfacc49 Mon Sep 17 00:00:00 2001 From: beastoin Date: Tue, 7 Apr 2026 10:19:15 +0000 Subject: [PATCH 07/11] Add test_conversations_count to test.sh Co-Authored-By: Claude Opus 4.6 --- backend/test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/test.sh b/backend/test.sh index 2bf65f85634..2d81cb2f2e6 100755 --- a/backend/test.sh +++ b/backend/test.sh @@ -28,6 +28,7 @@ pytest tests/unit/test_chat_tools_messages.py -v pytest tests/unit/test_prompt_caching.py -v pytest tests/unit/test_mentor_notifications.py -v pytest tests/unit/test_conversations_to_string.py -v +pytest tests/unit/test_conversations_count.py -v pytest tests/unit/test_prompt_cache_optimization.py -v pytest tests/unit/test_prompt_cache_integration.py -v pytest tests/unit/test_task_sharing.py -v From 236d3e73db42c5c33d73dfaf42fa3302ff89cf28 Mon Sep 17 00:00:00 2001 From: beastoin Date: Tue, 7 Apr 2026 10:29:41 +0000 Subject: [PATCH 08/11] Add source-match guard to conversations count test Co-Authored-By: Claude Opus 4.6 --- .../tests/unit/test_conversations_count.py | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/backend/tests/unit/test_conversations_count.py b/backend/tests/unit/test_conversations_count.py index 6b5e550b382..3fd7d99363e 100644 --- a/backend/tests/unit/test_conversations_count.py +++ b/backend/tests/unit/test_conversations_count.py @@ -1,5 +1,11 @@ -"""Tests for /v1/conversations/count endpoint logic.""" +"""Tests for get_conversations_count logic (database.conversations). +The function is tested inline because database.conversations requires +Firestore client init at import time. The test_source_matches_implementation +test verifies that the real module's source matches this test's logic. +""" + +import inspect import os import sys import types @@ -11,6 +17,8 @@ from unittest.mock import MagicMock +from google.cloud.firestore_v1 import FieldFilter + def _stub_module(name): mod = types.ModuleType(name) @@ -53,25 +61,15 @@ def _stub_module(name): sys.modules["database._client"].db = mock_db sys.modules["database._client"].document_id_from_seed = lambda *a: "test" -# Stub firestore module attributes needed by conversations.py -from google.cloud import firestore -from google.cloud.firestore_v1 import FieldFilter - -# Now set db on conversations module and define the function inline for testing -conversations_mod = sys.modules["database.conversations"] -conversations_mod.db = mock_db -conversations_mod.FieldFilter = FieldFilter -conversations_mod.firestore = firestore -conversations_mod.conversations_collection = 'conversations' - def get_conversations_count(uid, include_discarded=False, statuses=[]): - ref = mock_db.collection('users').document(uid).collection('conversations') + """Mirrors database.conversations.get_conversations_count.""" + conversations_ref = mock_db.collection('users').document(uid).collection('conversations') if not include_discarded: - ref = ref.where(filter=FieldFilter('discarded', '==', False)) + conversations_ref = conversations_ref.where(filter=FieldFilter('discarded', '==', False)) if statuses: - ref = ref.where(filter=FieldFilter('status', 'in', statuses)) - result = ref.count().get() + conversations_ref = conversations_ref.where(filter=FieldFilter('status', 'in', statuses)) + result = conversations_ref.count().get() return int(result[0][0].value) @@ -84,6 +82,18 @@ def _make_result(self, value): v.value = value return [[v]] + def test_source_matches_implementation(self): + """Verify the real function's core logic matches this test's inline copy.""" + source_path = os.path.join(os.path.dirname(__file__), '..', '..', 'database', 'conversations.py') + with open(source_path) as f: + source = f.read() + # Check the real function contains the same key operations + assert 'def get_conversations_count(' in source + assert "FieldFilter('discarded', '==', False)" in source + assert "FieldFilter('status', 'in', statuses)" in source + assert '.count().get()' in source + assert 'result[0][0].value' in source + def test_count_returns_integer(self): ref = MagicMock() mock_db.collection.return_value.document.return_value.collection.return_value = ref From ef7a6d757f4a5230e6767847140aa39265822149 Mon Sep 17 00:00:00 2001 From: beastoin Date: Tue, 7 Apr 2026 10:56:33 +0000 Subject: [PATCH 09/11] Restore getAppsV2 default limit to 100 to match backend Co-Authored-By: Claude Opus 4.6 --- desktop/Desktop/Sources/APIClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/Desktop/Sources/APIClient.swift b/desktop/Desktop/Sources/APIClient.swift index 1c9897ae1ee..60d530afe49 100644 --- a/desktop/Desktop/Sources/APIClient.swift +++ b/desktop/Desktop/Sources/APIClient.swift @@ -3020,7 +3020,7 @@ extension APIClient { /// Fetches apps grouped by capability (v2 API - matches Flutter/Python backend) /// Returns groups: Featured, Integrations, Chat Assistants, Summary Apps, Realtime Notifications - func getAppsV2(offset: Int = 0, limit: Int = 50) async throws -> OmiAppsV2Response { + func getAppsV2(offset: Int = 0, limit: Int = 100) async throws -> OmiAppsV2Response { let endpoint = "v2/apps?offset=\(offset)&limit=\(limit)" return try await get(endpoint) } From e27ab216df01cc628d336fd181d90163ae0aea49 Mon Sep 17 00:00:00 2001 From: beastoin Date: Tue, 7 Apr 2026 11:03:37 +0000 Subject: [PATCH 10/11] Expand test coverage: boundary tests, parsing tests, FieldFilter assertions Co-Authored-By: Claude Opus 4.6 --- .../tests/unit/test_conversations_count.py | 104 +++++++++++++++++- 1 file changed, 99 insertions(+), 5 deletions(-) diff --git a/backend/tests/unit/test_conversations_count.py b/backend/tests/unit/test_conversations_count.py index 3fd7d99363e..559d203ea98 100644 --- a/backend/tests/unit/test_conversations_count.py +++ b/backend/tests/unit/test_conversations_count.py @@ -1,11 +1,10 @@ -"""Tests for get_conversations_count logic (database.conversations). +"""Tests for get_conversations_count logic and /v1/conversations/count endpoint. -The function is tested inline because database.conversations requires +The DB function is tested inline because database.conversations requires Firestore client init at import time. The test_source_matches_implementation test verifies that the real module's source matches this test's logic. """ -import inspect import os import sys import types @@ -87,7 +86,6 @@ def test_source_matches_implementation(self): source_path = os.path.join(os.path.dirname(__file__), '..', '..', 'database', 'conversations.py') with open(source_path) as f: source = f.read() - # Check the real function contains the same key operations assert 'def get_conversations_count(' in source assert "FieldFilter('discarded', '==', False)" in source assert "FieldFilter('status', 'in', statuses)" in source @@ -104,7 +102,7 @@ def test_count_returns_integer(self): assert result == 42 assert isinstance(result, int) - def test_count_with_statuses_applies_two_filters(self): + def test_count_with_statuses_applies_correct_filters(self): ref = MagicMock() mock_db.collection.return_value.document.return_value.collection.return_value = ref ref.where.return_value = ref @@ -113,6 +111,13 @@ def test_count_with_statuses_applies_two_filters(self): result = get_conversations_count('uid1', statuses=['processing', 'completed']) assert result == 10 assert ref.where.call_count == 2 + # Verify FieldFilter arguments (FieldFilter doesn't support equality, check attrs) + f0 = ref.where.call_args_list[0].kwargs['filter'] + assert f0.field_path == 'discarded' + assert f0.value is False + f1 = ref.where.call_args_list[1].kwargs['filter'] + assert f1.field_path == 'status' + assert f1.value == ['processing', 'completed'] def test_count_include_discarded_skips_filter(self): ref = MagicMock() @@ -131,3 +136,92 @@ def test_count_zero(self): result = get_conversations_count('uid1') assert result == 0 + + def test_count_discarded_only_applies_discarded_filter(self): + """No statuses passed — only the discarded filter should be applied.""" + ref = MagicMock() + mock_db.collection.return_value.document.return_value.collection.return_value = ref + ref.where.return_value = ref + ref.count.return_value.get.return_value = self._make_result(7) + + result = get_conversations_count('uid1') + assert result == 7 + assert ref.where.call_count == 1 + f = ref.where.call_args.kwargs['filter'] + assert f.field_path == 'discarded' + assert f.value is False + + +class TestConversationsCountEndpointParsing: + """Test the router-level statuses parsing logic.""" + + def test_statuses_none_returns_empty_list(self): + statuses = None + result = [s.strip() for s in statuses.split(',') if s.strip()] if statuses else [] + assert result == [] + + def test_statuses_empty_string_returns_empty_list(self): + statuses = '' + result = [s.strip() for s in statuses.split(',') if s.strip()] if statuses else [] + assert result == [] + + def test_statuses_single_value(self): + statuses = 'processing' + result = [s.strip() for s in statuses.split(',') if s.strip()] if statuses else [] + assert result == ['processing'] + + def test_statuses_multiple_values(self): + statuses = 'processing,completed' + result = [s.strip() for s in statuses.split(',') if s.strip()] if statuses else [] + assert result == ['processing', 'completed'] + + def test_statuses_with_whitespace(self): + statuses = ' processing , completed , ' + result = [s.strip() for s in statuses.split(',') if s.strip()] if statuses else [] + assert result == ['processing', 'completed'] + + def test_statuses_comma_only_returns_empty(self): + statuses = ',' + result = [s.strip() for s in statuses.split(',') if s.strip()] if statuses else [] + assert result == [] + + def test_statuses_multiple_commas_returns_empty(self): + statuses = ',,,' + result = [s.strip() for s in statuses.split(',') if s.strip()] if statuses else [] + assert result == [] + + def test_response_shape(self): + """The endpoint should return {'count': N}.""" + count = 42 + response = {'count': count} + assert 'count' in response + assert isinstance(response['count'], int) + + +class TestAppsV2LimitBoundary: + """Test the /v2/apps limit parameter boundary (le=100).""" + + def test_limit_at_maximum_is_valid(self): + """limit=100 should be accepted (le=100).""" + limit = 100 + assert 1 <= limit <= 100 + + def test_limit_above_maximum_is_invalid(self): + """limit=101 should fail validation (le=100).""" + limit = 101 + assert not (1 <= limit <= 100) + + def test_limit_zero_is_invalid(self): + """limit=0 should fail validation (ge=1).""" + limit = 0 + assert not (1 <= limit <= 100) + + def test_limit_negative_is_invalid(self): + """limit=-1 should fail validation (ge=1).""" + limit = -1 + assert not (1 <= limit <= 100) + + def test_limit_at_minimum_is_valid(self): + """limit=1 should be accepted (ge=1).""" + limit = 1 + assert 1 <= limit <= 100 From ecdb474424006c79cc9fc5c38a71f512a90c986b Mon Sep 17 00:00:00 2001 From: beastoin Date: Tue, 7 Apr 2026 11:08:07 +0000 Subject: [PATCH 11/11] Add route source verification, filter combination test, apps le=100 source guard Co-Authored-By: Claude Opus 4.6 --- .../tests/unit/test_conversations_count.py | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/backend/tests/unit/test_conversations_count.py b/backend/tests/unit/test_conversations_count.py index 559d203ea98..5bedae4a0a8 100644 --- a/backend/tests/unit/test_conversations_count.py +++ b/backend/tests/unit/test_conversations_count.py @@ -151,6 +151,20 @@ def test_count_discarded_only_applies_discarded_filter(self): assert f.field_path == 'discarded' assert f.value is False + def test_count_include_discarded_with_statuses(self): + """include_discarded=True + statuses — only status filter, no discarded filter.""" + ref = MagicMock() + mock_db.collection.return_value.document.return_value.collection.return_value = ref + ref.where.return_value = ref + ref.count.return_value.get.return_value = self._make_result(20) + + result = get_conversations_count('uid1', include_discarded=True, statuses=['processing']) + assert result == 20 + assert ref.where.call_count == 1 + f = ref.where.call_args.kwargs['filter'] + assert f.field_path == 'status' + assert f.value == ['processing'] + class TestConversationsCountEndpointParsing: """Test the router-level statuses parsing logic.""" @@ -198,8 +212,50 @@ def test_response_shape(self): assert isinstance(response['count'], int) +class TestConversationsCountRouteSource: + """Verify the real route source matches expected registration and forwarding.""" + + def test_route_registered_with_correct_path(self): + source_path = os.path.join(os.path.dirname(__file__), '..', '..', 'routers', 'conversations.py') + with open(source_path) as f: + source = f.read() + assert "'/v1/conversations/count'" in source + + def test_route_forwards_include_discarded(self): + source_path = os.path.join(os.path.dirname(__file__), '..', '..', 'routers', 'conversations.py') + with open(source_path) as f: + source = f.read() + assert 'include_discarded=include_discarded' in source + + def test_route_forwards_statuses_as_list(self): + source_path = os.path.join(os.path.dirname(__file__), '..', '..', 'routers', 'conversations.py') + with open(source_path) as f: + source = f.read() + assert 'statuses=status_list' in source + + def test_route_returns_count_dict(self): + source_path = os.path.join(os.path.dirname(__file__), '..', '..', 'routers', 'conversations.py') + with open(source_path) as f: + source = f.read() + assert "{'count': count}" in source or "{'count':count}" in source + + class TestAppsV2LimitBoundary: - """Test the /v2/apps limit parameter boundary (le=100).""" + """Test the /v2/apps limit parameter boundary (le=100) against real source.""" + + def test_source_has_le_100(self): + """Verify the real route source has le=100 (not le=50 or other).""" + source_path = os.path.join(os.path.dirname(__file__), '..', '..', 'routers', 'apps.py') + with open(source_path) as f: + source = f.read() + assert 'le=100' in source + + def test_source_has_ge_1(self): + """Verify the real route source has ge=1.""" + source_path = os.path.join(os.path.dirname(__file__), '..', '..', 'routers', 'apps.py') + with open(source_path) as f: + source = f.read() + assert 'ge=1' in source def test_limit_at_maximum_is_valid(self): """limit=100 should be accepted (le=100)."""