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
30 changes: 30 additions & 0 deletions package/ai/tests/test_ai_config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,36 @@
pytestmark = [pytest.mark.smoke]


def test_get_model_selection_returns_none_for_unknown_task():
manager = AIConfigManager.__new__(AIConfigManager)
manager._initialized = True
manager._config = {"models": {}}

assert manager.get_model_selection("unknown") is None


def test_set_model_selection_rejects_unknown_model():
manager = AIConfigManager.__new__(AIConfigManager)
manager._initialized = True
manager._config = {"models": {"ocr": {"selected": "mobile", "available": ["mobile"]}}}

with pytest.raises(ValueError, match="Invalid model"):
manager.set_model_selection("ocr", "server")


def test_set_model_selection_saves_only_when_value_changes():
manager = AIConfigManager.__new__(AIConfigManager)
manager._initialized = True
manager._config = {"models": {"ocr": {"selected": "mobile", "available": ["mobile", "server"]}}}

with patch.object(manager, "_save_config") as save:
assert manager.set_model_selection("ocr", "server") is True
assert manager.set_model_selection("ocr", "server") is False

assert manager.get_model_selection("ocr") == "server"
save.assert_called_once_with()


def _fresh_manager(tmp_path, config_data=None, file_exists=True):
"""Build a fresh AIConfigManager with ``_config`` seeded and no I/O."""
mgr = AIConfigManager.__new__(AIConfigManager)
Expand Down
181 changes: 181 additions & 0 deletions package/server/tests/unit/test_agent_crud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Unit tests for ``app/crud/agent.py``.

Covers the session + message CRUD wrappers. ``db`` is mocked so we never
touch Postgres. The module's UUID coercion (str → UUID) is also covered.

Scenarios:
* get_session accepts a UUID string and queries by it
* get_sessions_by_user coerces a string user_id and orders by pinned/created
* create_session persists and refreshes
* update_session applies only the explicitly provided fields
* delete_session returns False when missing, True when removed
* get_messages_by_session coerces session id and orders by created_at
* create_message also bumps summary_update_time on the parent session
* delete_messages_by_session returns True even when nothing matched
"""

from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock
from uuid import UUID, uuid4

import pytest

from app.crud import agent as agent_crud
from app.schemas.agent import (
AgentMessageCreate,
AgentSessionCreate,
AgentSessionUpdate,
)


pytestmark = [pytest.mark.smoke, pytest.mark.module_agent]


# ---------------------------------------------------------------------------
# Session CRUD
# ---------------------------------------------------------------------------

def test_get_session_accepts_uuid_string_and_queries_by_uuid():
db = MagicMock()
expected = SimpleNamespace(id=uuid4(), user_id=uuid4())
db.query.return_value.filter.return_value.first.return_value = expected

out = agent_crud.get_session(db, str(expected.id))

db.query.assert_called_once()
# The filter clause was built with the UUID instance, not a raw string.
args, _ = db.query.return_value.filter.call_args
assert args[0].right.value == expected.id
assert isinstance(args[0].right.value, UUID)
assert out is expected


def test_get_sessions_by_user_orders_by_pinned_then_created():
db = MagicMock()
user_id = uuid4()
expected = [SimpleNamespace(id=uuid4()), SimpleNamespace(id=uuid4())]
db.query.return_value.filter.return_value.order_by.return_value \
.offset.return_value.limit.return_value.all.return_value = expected

out = agent_crud.get_sessions_by_user(db, str(user_id), skip=10, limit=25)

assert out is expected
# order_by is chained twice (pinned desc, created_at desc)\n order_mock = db.query.return_value.filter.return_value.order_by\n assert order_mock.call_count == 2
db.query.return_value.filter.return_value.order_by.return_value \
.offset.assert_called_once_with(10)
db.query.return_value.filter.return_value.order_by.return_value \
.offset.return_value.limit.assert_called_once_with(25)


def test_create_session_persists_payload_and_user_id():
db = MagicMock()
user_id = uuid4()
payload = AgentSessionCreate(title="trip chat", is_pinned=True)

out = agent_crud.create_session(db, payload, user_id)

db.add.assert_called_once()
db.commit.assert_called_once()
db.refresh.assert_called_once()
# The added object carries coerced UUID + payload fields.
added = db.add.call_args[0][0]
assert added.user_id == user_id
assert isinstance(added.user_id, UUID)
assert added.title == "trip chat"
assert added.is_pinned is True
# create_session returns the same instance it refreshed.
assert out is added


def test_update_session_only_applies_supplied_fields():
db = MagicMock()
db_obj = SimpleNamespace(title="old", status="active", is_pinned=False)
# Only `title` is explicitly supplied; the other two stay untouched.
payload = AgentSessionUpdate(title="new")

out = agent_crud.update_session(db, db_obj, payload)

assert db_obj.title == "new"
# status & is_pinned were not set in the payload → not in exclude_unset dump
assert db_obj.status == "active"
assert db_obj.is_pinned is False
db.add.assert_called_once_with(db_obj)
db.commit.assert_called_once()
assert out is db_obj


def test_delete_session_returns_false_when_not_found():
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None

assert agent_crud.delete_session(db, str(uuid4())) is False
db.delete.assert_not_called()
db.commit.assert_not_called()


def test_delete_session_returns_true_when_removed():
db = MagicMock()
target = SimpleNamespace(id=uuid4())
db.query.return_value.filter.return_value.first.return_value = target

assert agent_crud.delete_session(db, str(target.id)) is True
db.delete.assert_called_once_with(target)
db.commit.assert_called_once()


# ---------------------------------------------------------------------------
# Message CRUD
# ---------------------------------------------------------------------------

def test_get_messages_by_session_orders_by_created_at_ascending():
db = MagicMock()
session_id = uuid4()
expected = [SimpleNamespace(id=1), SimpleNamespace(id=2)]
db.query.return_value.filter.return_value.order_by.return_value \
.offset.return_value.limit.return_value.all.return_value = expected

out = agent_crud.get_messages_by_session(db, str(session_id), skip=0, limit=50)

assert out is expected
db.query.return_value.filter.return_value.order_by.return_value \
.offset.assert_called_once_with(0)
db.query.return_value.filter.return_value.order_by.return_value \
.offset.return_value.limit.assert_called_once_with(50)


def test_create_message_bumps_parent_session_summary_time():
db = MagicMock()
parent = SimpleNamespace(summary_update_time=None)
db.query.return_value.filter.return_value.first.return_value = parent
payload = AgentMessageCreate(
session_id=uuid4(), role="user", content="hi", token_count=3
)

agent_crud.create_message(db, payload)

assert isinstance(parent.summary_update_time, datetime)
db.add.assert_called() # both message and session were added
db.commit.assert_called_once()
db.refresh.assert_called_once()


def test_create_message_succeeds_even_without_matching_session():
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
payload = AgentMessageCreate(session_id=uuid4(), role="assistant", content="ok")

agent_crud.create_message(db, payload)

db.commit.assert_called_once()
# Only the message itself was added; no session row to touch.
assert db.add.call_count == 1


def test_delete_messages_by_session_returns_true_regardless_of_matches():
db = MagicMock()
db.query.return_value.filter.return_value.delete.return_value = 0

assert agent_crud.delete_messages_by_session(db, str(uuid4())) is True
db.query.return_value.filter.return_value.delete.assert_called_once()
db.commit.assert_called_once()
70 changes: 70 additions & 0 deletions package/server/tests/unit/test_basic_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Nightly watch gap coverage for app.service.tasks.basic.

Targets the batch wrapper and resource release hooks in basic.py
(178/212 lines missed in nightly coverage scan).

* Happy path: batch returns one result per input task with task_id echoed.
* Edge: empty input returns empty list.
* Error: a single failure inside the per-task processor is captured in the
returned result dict instead of raising.
"""

from __future__ import annotations

from unittest.mock import patch

import pytest

from app.service.tasks import basic as basic_task


pytestmark = [pytest.mark.smoke, pytest.mark.module_photo]


def test_process_basic_cpu_batch_job_empty_input():
assert basic_task.process_basic_cpu_batch_job([]) == []


def test_process_basic_cpu_batch_job_returns_per_task_results():
fake_results = [
{"success": True, "thumb_path": "a.jpg"},
{"success": True, "thumb_path": "b.jpg"},
]
with patch("app.service.tasks.basic.process_basic_cpu_job", side_effect=fake_results):
tasks = [
{"task_id": "t1", "file_path": "a", "file_id": "f1", "storage_root": "/r", "user_id": "u1"},
{"task_id": "t2", "file_path": "b", "file_id": "f2", "storage_root": "/r", "user_id": "u2"},
]
results = basic_task.process_basic_cpu_batch_job(tasks)
assert len(results) == 2
assert results[0]["task_id"] == "t1"
assert results[0]["success"] is True
assert results[1]["task_id"] == "t2"


def test_process_basic_cpu_batch_job_keeps_going_after_failure():
fake_results = [
{"success": False, "error": "boom"},
{"success": True, "thumb_path": "ok.jpg"},
]
with patch("app.service.tasks.basic.process_basic_cpu_job", side_effect=fake_results):
tasks = [
{"task_id": "t1", "file_path": "a", "file_id": "f1", "storage_root": "/r", "user_id": "u1"},
{"task_id": "t2", "file_path": "b", "file_id": "f2", "storage_root": "/r", "user_id": "u2"},
]
results = basic_task.process_basic_cpu_batch_job(tasks)
# Both results should be returned; batch keeps iterating.
assert len(results) == 2
assert results[0]["error"] == "boom"
assert results[1]["success"] is True


def test_release_resources_is_noop():
# The hook currently is a no-op (placeholder for future cleanup).
assert basic_task.release_resources() is None


def test_basic_task_strategy_task_category():
# Registered at import time; just verify the marker is set.
strategy = basic_task.BasicTaskStrategy()
assert strategy.task_category == "CPU"
46 changes: 46 additions & 0 deletions package/server/tests/unit/test_cluster_crud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
from uuid import uuid4

import pytest

from app.crud import cluster


pytestmark = pytest.mark.smoke


def test_remove_photo_from_clusters_is_noop_when_unlinked():
db = MagicMock()
db.query.return_value.filter.return_value.all.return_value = []

assert cluster.remove_photo_from_clusters(db, uuid4()) is None
db.delete.assert_not_called()


def test_remove_photo_deletes_singleton_cluster():
db = MagicMock()
photo_cluster = SimpleNamespace(cluster_id="cluster-1")
image_cluster = SimpleNamespace(count=1)
db.query.return_value.filter.return_value.all.return_value = [photo_cluster]
db.query.return_value.filter.return_value.first.return_value = image_cluster

cluster.remove_photo_from_clusters(db, uuid4())

assert db.delete.call_args_list == [
((photo_cluster,), {}),
((image_cluster,), {}),
]


def test_remove_photo_decrements_shared_cluster_without_deleting_it():
db = MagicMock()
photo_cluster = SimpleNamespace(cluster_id="cluster-2")
image_cluster = SimpleNamespace(count=3)
db.query.return_value.filter.return_value.all.return_value = [photo_cluster]
db.query.return_value.filter.return_value.first.return_value = image_cluster

cluster.remove_photo_from_clusters(db, uuid4())

assert image_cluster.count == 2
db.delete.assert_called_once_with(photo_cluster)
Loading
Loading