Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/database/conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion backend/routers/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions backend/routers/conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}')
Expand Down
1 change: 1 addition & 0 deletions backend/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
283 changes: 283 additions & 0 deletions backend/tests/unit/test_conversations_count.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
"""Tests for get_conversations_count logic and /v1/conversations/count endpoint.

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 os
import sys
import types

os.environ.setdefault(
"ENCRYPTION_SECRET",
"omi_ZwB2ZNqB2HHpMK6wStk7sTpavJiPTFg7gXUHnc4tFABPU6pZ2c2DKgehtfgi4RZv",
)

from unittest.mock import MagicMock

from google.cloud.firestore_v1 import FieldFilter


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"


def get_conversations_count(uid, include_discarded=False, statuses=[]):
"""Mirrors database.conversations.get_conversations_count."""
conversations_ref = mock_db.collection('users').document(uid).collection('conversations')
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)


class TestConversationsCount:
def setup_method(self):
mock_db.reset_mock()

def _make_result(self, value):
v = MagicMock()
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()
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
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_correct_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
# 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()
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

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

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."""

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 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) 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)."""
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
12 changes: 6 additions & 6 deletions desktop/Desktop/Sources/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1453,7 +1453,7 @@ struct ActionItemsListResponse: Codable {
let hasMore: Bool

enum CodingKeys: String, CodingKey {
case items
case items = "action_items"
case hasMore = "has_more"
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions desktop/Desktop/Sources/MainWindow/Pages/AppsPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Comment on lines +305 to +308
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 apps.isEmpty as retry guard may cause repeated fetches

Using apps.isEmpty as the proxy for "fetch has not yet succeeded" means that if the backend ever returns a successful response with zero apps (e.g., all apps are unpublished or a backend filtering bug), isLoading resets to false and apps stays [], so fetchApps() re-fires on every navigation to AppsPage. A dedicated hasFetchedApps: Bool flag in AppProvider, set to true after any completed fetch (success or failure), would remove this edge case entirely:

// In AppProvider
var hasFetchedApps = false

func fetchApps() async {
    isLoading = true
    defer {
        isLoading = false
        hasFetchedApps = true
        ...
    }
    ...
}
// onAppear guard
if !hasFetchedApps && !appProvider.isLoading {
    Task { await appProvider.fetchApps() }
}

In practice this is harmless with the paired backend limit fix (the marketplace always returns >0 apps), but it is fragile by design.

}
}
.task {
await connectorStatusStore.refresh()
Expand Down
Loading