diff --git a/AGENTS.md b/AGENTS.md index 68d95ce6..8ab8793c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,9 +10,15 @@ For feature work, do **not** assume the environment needs to be recreated. - Check whether the user already has a Python virtual environment such as `venv/` or `.venv/`. -- Check whether MongoDB is already running. +- Check whether Postgres is already running and reachable via `POSTGRES_URI` (the canonical user-data store). - Check whether Redis is already running. -- Reuse what is already working. Do not stop or recreate MongoDB, Redis, or the Python environment unless the task is environment setup or troubleshooting. +- Reuse what is already working. Do not stop or recreate Postgres, Redis, or the Python environment unless the task is environment setup or troubleshooting. + +> MongoDB is **not** required for the default install. It is only needed if +> the user opts into the Mongo vector-store backend (`VECTOR_STORE=mongodb`) +> or is running the one-shot `scripts/db/backfill.py` to migrate existing +> user data from the legacy Mongo-based install. In those cases, `pymongo` +> is available as an optional extra, not a core dependency. ## Normal local development commands diff --git a/application/agents/tool_executor.py b/application/agents/tool_executor.py index 01032277..dbacbe87 100644 --- a/application/agents/tool_executor.py +++ b/application/agents/tool_executor.py @@ -351,6 +351,17 @@ class ToolExecutor: headers=headers, query_params=query_params, ) + if tool is None: + error_message = ( + f"Failed to load tool '{tool_data.get('name')}' (tool_id key={tool_id}): " + "missing 'id' on tool row." + ) + logger.error(error_message) + tool_call_data["result"] = error_message + yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}} + self.tool_calls.append(tool_call_data) + return error_message, call_id + resolved_arguments = ( {"query_params": query_params, "headers": headers, "body": body} if tool_data["name"] == "api_tool" @@ -437,7 +448,16 @@ class ToolExecutor: tool_config.update(decrypted) tool_config["auth_credentials"] = decrypted tool_config.pop("encrypted_credentials", None) - tool_config["tool_id"] = str(tool_data.get("_id", tool_id)) + row_id = tool_data.get("id") + if not row_id: + logger.error( + "Tool data missing 'id' for tool name=%s (enumerate-key tool_id=%s); " + "skipping load to avoid binding a non-UUID downstream.", + tool_data.get("name"), + tool_id, + ) + return None + tool_config["tool_id"] = str(row_id) if self.conversation_id: tool_config["conversation_id"] = self.conversation_id if tool_data["name"] == "mcp_tool": diff --git a/application/agents/tools/memory.py b/application/agents/tools/memory.py index 0997764d..d456dcd1 100644 --- a/application/agents/tools/memory.py +++ b/application/agents/tools/memory.py @@ -59,6 +59,14 @@ class MemoryTool(Tool): tool_id, ) return False + from application.storage.db.base_repository import looks_like_uuid + + if not looks_like_uuid(tool_id): + logger.debug( + "Skipping Postgres operation for MemoryTool with non-UUID tool_id=%s", + tool_id, + ) + return False return True # ----------------------------- diff --git a/application/agents/tools/notes.py b/application/agents/tools/notes.py index 360b1eb0..4d848e5a 100644 --- a/application/agents/tools/notes.py +++ b/application/agents/tools/notes.py @@ -55,7 +55,9 @@ class NotesTool(Tool): return False if tool_id.startswith("default_"): return False - return True + from application.storage.db.base_repository import looks_like_uuid + + return looks_like_uuid(tool_id) # ----------------------------- # Action implementations diff --git a/application/agents/tools/todo_list.py b/application/agents/tools/todo_list.py index 1a8fc7ee..58553fec 100644 --- a/application/agents/tools/todo_list.py +++ b/application/agents/tools/todo_list.py @@ -60,7 +60,9 @@ class TodoListTool(Tool): return False if tool_id.startswith("default_"): return False - return True + from application.storage.db.base_repository import looks_like_uuid + + return looks_like_uuid(tool_id) # ----------------------------- # Action implementations diff --git a/application/api/user/conversations/routes.py b/application/api/user/conversations/routes.py index de0d3bbe..6330cd3a 100644 --- a/application/api/user/conversations/routes.py +++ b/application/api/user/conversations/routes.py @@ -273,6 +273,11 @@ class SubmitFeedback(Resource): user_id = decoded_token.get("sub") feedback_value = data["feedback"] question_index = int(data["question_index"]) + # Normalize string feedback to lowercase so analytics queries + # (which match 'like'/'dislike') count rows correctly. Tolerate + # legacy uppercase clients on ingest. Non-string values pass through. + if isinstance(feedback_value, str): + feedback_value = feedback_value.lower() feedback_payload = ( None if feedback_value is None diff --git a/application/api/user/sources/routes.py b/application/api/user/sources/routes.py index d934610b..c38b4d60 100644 --- a/application/api/user/sources/routes.py +++ b/application/api/user/sources/routes.py @@ -158,27 +158,6 @@ class PaginatedSources(Resource): return make_response(jsonify({"success": False}), 400) -@sources_ns.route("/delete_by_ids") -class DeleteByIds(Resource): - @api.doc( - description="Deletes documents from the vector store by IDs", - params={"path": "Comma-separated list of IDs"}, - ) - def get(self): - ids = request.args.get("path") - if not ids: - return make_response( - jsonify({"success": False, "message": "Missing required fields"}), 400 - ) - # TODO(pg-cutover): vector-store-level ``delete_index(ids=...)`` used - # to hang off the legacy ``sources_collection`` wrapper. That path - # goes through the vector store, not user-data Postgres — left as a - # follow-up since this endpoint is admin-only. - return make_response( - jsonify({"success": False, "message": "Not implemented"}), 501 - ) - - @sources_ns.route("/delete_old") class DeleteOldIndexes(Resource): @api.doc( diff --git a/application/api/user/utils.py b/application/api/user/utils.py index a7cc6af4..6f6dfb40 100644 --- a/application/api/user/utils.py +++ b/application/api/user/utils.py @@ -1,298 +1,61 @@ """Centralized utilities for API routes. -TODO(pg-cutover): ``validate_object_id``, ``check_resource_ownership``, -``paginated_response``, and ``serialize_object_id`` remain Mongo-shaped -because ``application/api/user/workflows/routes.py`` still depends on -them (Agent B's slice). Once workflows flip to PG, this module's Mongo -imports (``bson``, ``pymongo.collection.Collection``) can be dropped -along with the helpers above. +Post-Mongo-cutover slim: the old Mongo-shaped helpers (``validate_object_id``, +``check_resource_ownership``, ``paginated_response``, ``serialize_object_id``, +``safe_db_operation``, ``validate_enum``, ``extract_sort_params``) have been +removed — they carried ``bson`` / ``pymongo`` imports and had zero callers. """ from functools import wraps -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Callable, Optional -from bson.errors import InvalidId -from bson.objectid import ObjectId from flask import ( Response, - current_app, - has_app_context, jsonify, make_response, request, ) -from pymongo.collection import Collection def get_user_id() -> Optional[str]: - """ - Extract user ID from decoded JWT token. - - Returns: - User ID string or None if not authenticated - """ + """Extract user ID from decoded JWT token, or None if unauthenticated.""" decoded_token = getattr(request, "decoded_token", None) return decoded_token.get("sub") if decoded_token else None def require_auth(func: Callable) -> Callable: - """ - Decorator to require authentication for route handlers. - - Usage: - @require_auth - def get(self): - user_id = get_user_id() - ... - """ + """Decorator to require authentication. Returns 401 when absent.""" @wraps(func) def wrapper(*args, **kwargs): user_id = get_user_id() if not user_id: - return error_response("Unauthorized", 401) + return make_response(jsonify({"success": False, "error": "Unauthorized"}), 401) return func(*args, **kwargs) return wrapper def success_response( - data: Optional[Dict[str, Any]] = None, status: int = 200 + data=None, message: Optional[str] = None, status: int = 200 ) -> Response: - """ - Create a standardized success response. - - Args: - data: Optional data dictionary to include in response - status: HTTP status code (default: 200) - - Returns: - Flask Response object - - Example: - return success_response({"users": [...], "total": 10}) - """ - response = {"success": True} - if data: - response.update(data) - return make_response(jsonify(response), status) + """Shape a successful JSON response.""" + body = {"success": True} + if data is not None: + body["data"] = data + if message is not None: + body["message"] = message + return make_response(jsonify(body), status) def error_response(message: str, status: int = 400, **kwargs) -> Response: - """ - Create a standardized error response. - - Args: - message: Error message string - status: HTTP status code (default: 400) - **kwargs: Additional fields to include in response - - Returns: - Flask Response object - - Example: - return error_response("Resource not found", 404) - return error_response("Invalid input", 400, errors=["field1", "field2"]) - """ - response = {"success": False, "message": message} - response.update(kwargs) - return make_response(jsonify(response), status) + """Shape an error JSON response; any kwargs are merged into the body.""" + body = {"success": False, "error": message, **kwargs} + return make_response(jsonify(body), status) -def validate_object_id( - id_string: str, resource_name: str = "Resource" -) -> Tuple[Optional[ObjectId], Optional[Response]]: - """ - Validate and convert string to ObjectId. - - Args: - id_string: String to convert - resource_name: Name of resource for error message - - Returns: - Tuple of (ObjectId or None, error_response or None) - - Example: - obj_id, error = validate_object_id(workflow_id, "Workflow") - if error: - return error - """ - try: - return ObjectId(id_string), None - except (InvalidId, TypeError): - return None, error_response(f"Invalid {resource_name} ID format") - - -def validate_pagination( - default_limit: int = 20, max_limit: int = 100 -) -> Tuple[int, int, Optional[Response]]: - """ - Extract and validate pagination parameters from request. - - Args: - default_limit: Default items per page - max_limit: Maximum allowed items per page - - Returns: - Tuple of (limit, skip, error_response or None) - - Example: - limit, skip, error = validate_pagination() - if error: - return error - """ - try: - limit = min(int(request.args.get("limit", default_limit)), max_limit) - skip = int(request.args.get("skip", 0)) - if limit < 1 or skip < 0: - return 0, 0, error_response("Invalid pagination parameters") - return limit, skip, None - except ValueError: - return 0, 0, error_response("Invalid pagination parameters") - - -def check_resource_ownership( - collection: Collection, - resource_id: ObjectId, - user_id: str, - resource_name: str = "Resource", -) -> Tuple[Optional[Dict], Optional[Response]]: - """ - Check if resource exists and belongs to user. - - Args: - collection: MongoDB collection - resource_id: Resource ObjectId - user_id: User ID string - resource_name: Name of resource for error messages - - Returns: - Tuple of (resource_dict or None, error_response or None) - - Example: - resource, error = check_resource_ownership( - some_collection, - resource_id, - user_id, - "Resource" - ) - if error: - return error - """ - resource = collection.find_one({"_id": resource_id, "user": user_id}) - if not resource: - return None, error_response(f"{resource_name} not found", 404) - return resource, None - - -def serialize_object_id( - obj: Dict[str, Any], id_field: str = "_id", new_field: str = "id" -) -> Dict[str, Any]: - """ - Convert ObjectId to string in a dictionary. - - Args: - obj: Dictionary containing ObjectId - id_field: Field name containing ObjectId - new_field: New field name for string ID - - Returns: - Modified dictionary - - Example: - user = serialize_object_id(user_doc) - # user["id"] = "507f1f77bcf86cd799439011" - """ - if id_field in obj: - obj[new_field] = str(obj[id_field]) - if id_field != new_field: - obj.pop(id_field, None) - return obj - - -def serialize_list(items: List[Dict], serializer: Callable[[Dict], Dict]) -> List[Dict]: - """ - Apply serializer function to list of items. - - Args: - items: List of dictionaries - serializer: Function to apply to each item - - Returns: - List of serialized items - - Example: - workflows = serialize_list(workflow_docs, serialize_workflow) - """ - return [serializer(item) for item in items] - - -def paginated_response( - collection: Collection, - query: Dict[str, Any], - serializer: Callable[[Dict], Dict], - limit: int, - skip: int, - sort_field: str = "created_at", - sort_order: int = -1, - response_key: str = "items", -) -> Response: - """ - Create paginated response for collection query. - - Args: - collection: MongoDB collection - query: Query dictionary - serializer: Function to serialize each item - limit: Items per page - skip: Number of items to skip - sort_field: Field to sort by - sort_order: Sort order (1=asc, -1=desc) - response_key: Key name for items in response - - Returns: - Flask Response with paginated data - - Example: - return paginated_response( - some_collection, - {"user": user_id}, - serialize_resource, - limit, skip, - response_key="resources" - ) - """ - items = list( - collection.find(query).sort(sort_field, sort_order).skip(skip).limit(limit) - ) - total = collection.count_documents(query) - - return success_response( - { - response_key: serialize_list(items, serializer), - "total": total, - "limit": limit, - "skip": skip, - } - ) - - -def require_fields(required: List[str]) -> Callable: - """ - Decorator to validate required fields in request JSON. - - Args: - required: List of required field names - - Returns: - Decorator function - - Example: - @require_fields(["name", "description"]) - def post(self): - data = request.get_json() - ... - """ +def require_fields(required: list) -> Callable: + """Decorator: return 400 if any listed field is missing/falsy in the JSON body.""" def decorator(func: Callable) -> Callable: @wraps(func) @@ -302,94 +65,11 @@ def require_fields(required: List[str]) -> Callable: return error_response("Request body required") missing = [field for field in required if not data.get(field)] if missing: - return error_response(f"Missing required fields: {', '.join(missing)}") + return error_response( + f"Missing required fields: {', '.join(missing)}" + ) return func(*args, **kwargs) return wrapper return decorator - - -def safe_db_operation( - operation: Callable, error_message: str = "Database operation failed" -) -> Tuple[Any, Optional[Response]]: - """ - Safely execute database operation with error handling. - - Args: - operation: Function to execute - error_message: Error message if operation fails - - Returns: - Tuple of (result or None, error_response or None) - - Example: - result, error = safe_db_operation( - lambda: collection.insert_one(doc), - "Failed to create resource" - ) - if error: - return error - """ - try: - result = operation() - return result, None - except Exception as err: - if has_app_context(): - current_app.logger.error(f"{error_message}: {err}", exc_info=True) - return None, error_response(error_message) - - -def validate_enum( - value: Any, allowed: List[Any], field_name: str -) -> Optional[Response]: - """ - Validate that value is in allowed list. - - Args: - value: Value to validate - allowed: List of allowed values - field_name: Field name for error message - - Returns: - error_response if invalid, None if valid - - Example: - error = validate_enum(status, ["draft", "published"], "status") - if error: - return error - """ - if value not in allowed: - allowed_str = ", ".join(f"'{v}'" for v in allowed) - return error_response(f"Invalid {field_name}. Must be one of: {allowed_str}") - return None - - -def extract_sort_params( - default_field: str = "created_at", - default_order: str = "desc", - allowed_fields: Optional[List[str]] = None, -) -> Tuple[str, int]: - """ - Extract and validate sort parameters from request. - - Args: - default_field: Default sort field - default_order: Default sort order ("asc" or "desc") - allowed_fields: List of allowed sort fields (None = no validation) - - Returns: - Tuple of (sort_field, sort_order) - - Example: - sort_field, sort_order = extract_sort_params( - allowed_fields=["name", "date", "status"] - ) - """ - sort_field = request.args.get("sort", default_field) - sort_order_str = request.args.get("order", default_order).lower() - - if allowed_fields and sort_field not in allowed_fields: - sort_field = default_field - sort_order = -1 if sort_order_str == "desc" else 1 - return sort_field, sort_order diff --git a/application/core/settings.py b/application/core/settings.py index 469135ff..f8d2fb69 100644 --- a/application/core/settings.py +++ b/application/core/settings.py @@ -26,9 +26,8 @@ class Settings(BaseSettings): CELERY_BROKER_URL: str = "redis://localhost:6379/0" CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1" - # Consulted only by the Mongo vector-store backend and the one-shot - # backfill migration script; user data lives exclusively in Postgres. - MONGO_URI: str = "mongodb://localhost:27017/docsgpt" + # Only consulted when VECTOR_STORE=mongodb or when running scripts/db/backfill.py; user data lives in Postgres. + MONGO_URI: Optional[str] = None # User-data Postgres DB. POSTGRES_URI: Optional[str] = None LLM_PATH: str = os.path.join(current_dir, "models/docsgpt-7b-f16.gguf") diff --git a/application/requirements.txt b/application/requirements.txt index cd4ff410..af2f249c 100644 --- a/application/requirements.txt +++ b/application/requirements.txt @@ -64,7 +64,6 @@ py==1.11.0 pydantic pydantic-core pydantic-settings -pymongo==4.16.0 pypdf==6.9.2 python-dateutil==2.9.0.post0 python-dotenv diff --git a/application/seed/seeder.py b/application/seed/seeder.py index 319484c6..931f0c8e 100644 --- a/application/seed/seeder.py +++ b/application/seed/seeder.py @@ -107,6 +107,19 @@ class DatabaseSeeder: continue self.logger.info("Database seeding completed") + @staticmethod + def _coerce_uuid_fk(raw) -> Optional[str]: + """Coerce sentinel/blank values to ``None`` for nullable UUID FK columns. + + Mirrors the route-side handling in ``application/api/user/agents/routes.py``: + the literal string ``"default"``, empty string, and ``None`` all map + to ``None`` so the repository layer skips the column and Postgres + keeps the FK NULL (FKs are ``ON DELETE SET NULL``). + """ + if raw in (None, "", "default"): + return None + return str(raw) + def _upsert_agent( self, agent_config: Dict, @@ -116,17 +129,27 @@ class DatabaseSeeder: ) -> None: """Create or update a template agent owned by ``__system__``.""" name = agent_config["name"] + prompt_id_val = self._coerce_uuid_fk( + prompt_id if prompt_id is not None else agent_config.get("prompt_id") + ) + folder_id_val = self._coerce_uuid_fk(agent_config.get("folder_id")) + workflow_id_val = self._coerce_uuid_fk(agent_config.get("workflow_id")) + source_id_val = self._coerce_uuid_fk(source_id) agent_fields = { "description": agent_config["description"], "image": agent_config.get("image", ""), "tools": [str(tid) for tid in tool_ids], "agent_type": agent_config["agent_type"], - "prompt_id": prompt_id or agent_config.get("prompt_id", "default"), + "prompt_id": prompt_id_val, "chunks": agent_config.get("chunks", "0"), "retriever": agent_config.get("retriever", ""), } - if source_id: - agent_fields["source_id"] = str(source_id) + if folder_id_val is not None: + agent_fields["folder_id"] = folder_id_val + if workflow_id_val is not None: + agent_fields["workflow_id"] = workflow_id_val + if source_id_val is not None: + agent_fields["source_id"] = source_id_val with db_session() as conn: repo = AgentsRepository(conn) diff --git a/application/storage/db/repositories/attachments.py b/application/storage/db/repositories/attachments.py index 398a2b3c..2bcf88f6 100644 --- a/application/storage/db/repositories/attachments.py +++ b/application/storage/db/repositories/attachments.py @@ -86,6 +86,65 @@ class AttachmentsRepository: return row return self.get_by_legacy_id(attachment_id, user_id) + def resolve_ids(self, ids: list[str]) -> dict[str, str]: + """Batch-resolve a list of attachment ids (PG UUID *or* Mongo + ObjectId or post-cutover route-minted UUID stored only in + ``legacy_mongo_id``) to their canonical PG ``attachments.id``. + + Returns a ``{input_id: pg_uuid}`` map. Inputs that don't match + any row are simply absent from the map (caller decides whether + to drop or keep). Single round-trip via ``= ANY(:ids)`` to + avoid N+1. + + Resolution prefers ``legacy_mongo_id`` matches first, since + the post-cutover ``/store_attachment`` route mints a UUID that + is UUID-shaped but only ever lives in ``legacy_mongo_id`` + (the row's own ``id`` is a fresh PG-generated UUID). A + UUID-shaped input that is *also* a real ``attachments.id`` + falls back to the direct PK match. + """ + if not ids: + return {} + # Deduplicate while preserving order for stable output mapping. + unique_ids: list[str] = [] + seen: set[str] = set() + for raw in ids: + if raw is None: + continue + s = str(raw) + if s in seen: + continue + seen.add(s) + unique_ids.append(s) + if not unique_ids: + return {} + result = self._conn.execute( + text( + "SELECT id::text AS id, legacy_mongo_id " + "FROM attachments " + "WHERE legacy_mongo_id = ANY(:ids) " + "OR id::text = ANY(:ids)" + ), + {"ids": unique_ids}, + ) + rows = result.fetchall() + # Build two indexes so we can apply the legacy-first preference. + by_legacy: dict[str, str] = {} + by_pk: dict[str, str] = {} + for row in rows: + pg_id = str(row[0]) + legacy = row[1] + by_pk[pg_id] = pg_id + if legacy is not None: + by_legacy[str(legacy)] = pg_id + out: dict[str, str] = {} + for s in unique_ids: + if s in by_legacy: + out[s] = by_legacy[s] + elif s in by_pk: + out[s] = by_pk[s] + return out + def get_by_legacy_id(self, legacy_mongo_id: str, user_id: str | None = None) -> Optional[dict]: """Fetch an attachment by the original Mongo ObjectId string.""" legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None diff --git a/application/storage/db/repositories/conversations.py b/application/storage/db/repositories/conversations.py index c91f8377..dccf2901 100644 --- a/application/storage/db/repositories/conversations.py +++ b/application/storage/db/repositories/conversations.py @@ -89,34 +89,47 @@ class ConversationsRepository: def _resolve_attachment_refs( self, ids: list[str] | None, ) -> list[str]: - """Translate a list of attachment ids (Mongo ObjectId or UUID) - to PG attachment UUIDs. + """Translate a list of attachment ids to canonical PG + ``attachments.id`` UUIDs. - Unknown ids (no matching ``attachments.legacy_mongo_id``) are - dropped rather than raising — they'd have failed the ``uuid[]`` - cast otherwise and the entire row would have vanished via - dual-write's exception swallow. + Inputs may be: + + - A Mongo ObjectId string (24-hex), legacy dual-write era — + must be looked up via ``attachments.legacy_mongo_id``. + - A UUID string that is a real ``attachments.id`` PK. + - A UUID string that is *only* present as + ``attachments.legacy_mongo_id`` — this is the post-cutover + shape: ``/store_attachment`` mints a UUID, hands it to the + worker, and the worker stashes it in ``legacy_mongo_id`` + while the row gets a freshly-generated PK. Trusting the + input UUID as a PK here orphans the array entry: the column + is ``uuid[]`` (no FK), so PG accepts the bad value and all + downstream reads via ``AttachmentsRepository.get_any`` miss. + + Resolution therefore tries ``legacy_mongo_id`` first for every + id (UUID-shaped or not), then falls back to the direct PK + match. Unknown ids are dropped — they'd have failed the + ``uuid[]`` cast otherwise and the whole row would have vanished + via dual-write's exception swallow. """ if not ids: return [] + # Defer to AttachmentsRepository for the batched lookup so the + # legacy-first semantics live in one place. + from application.storage.db.repositories.attachments import ( + AttachmentsRepository, + ) + + clean: list[str] = [str(raw) for raw in ids if raw is not None] + if not clean: + return [] + repo = AttachmentsRepository(self._conn) + mapping = repo.resolve_ids(clean) out: list[str] = [] - for raw in ids: - if raw is None: - continue - value = str(raw) - if _looks_like_uuid(value): - out.append(value) - continue - result = self._conn.execute( - text( - "SELECT id FROM attachments " - "WHERE legacy_mongo_id = :lid LIMIT 1" - ), - {"lid": value}, - ) - row = result.fetchone() - if row is not None: - out.append(str(row[0])) + for value in clean: + mapped = mapping.get(value) + if mapped is not None: + out.append(mapped) return out # ------------------------------------------------------------------ diff --git a/deployment/docker-compose-azure.yaml b/deployment/docker-compose-azure.yaml index de62d3a0..0595794f 100644 --- a/deployment/docker-compose-azure.yaml +++ b/deployment/docker-compose-azure.yaml @@ -17,7 +17,6 @@ services: # Override URLs to use docker service names - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/1 - - MONGO_URI=mongodb://mongo:27017/docsgpt ports: - "7091:7091" volumes: @@ -26,7 +25,6 @@ services: - ../application/vectors:/app/application/vectors depends_on: - redis - - mongo worker: build: ../application @@ -37,25 +35,11 @@ services: # Override URLs to use docker service names - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/1 - - MONGO_URI=mongodb://mongo:27017/docsgpt - API_URL=http://backend:7091 depends_on: - redis - - mongo redis: image: redis:6-alpine ports: - 6379:6379 - - mongo: - image: mongo:6 - ports: - - 27017:27017 - volumes: - - mongodb_data_container:/data/db - - - -volumes: - mongodb_data_container: \ No newline at end of file diff --git a/deployment/docker-compose-dev.yaml b/deployment/docker-compose-dev.yaml index a1658bd2..0e15a2a3 100644 --- a/deployment/docker-compose-dev.yaml +++ b/deployment/docker-compose-dev.yaml @@ -5,15 +5,3 @@ services: image: redis:6-alpine ports: - 6379:6379 - - mongo: - image: mongo:6 - ports: - - 27017:27017 - volumes: - - mongodb_data_container:/data/db - - - -volumes: - mongodb_data_container: \ No newline at end of file diff --git a/deployment/docker-compose-hub.yaml b/deployment/docker-compose-hub.yaml index 07c621bb..84ec32e2 100644 --- a/deployment/docker-compose-hub.yaml +++ b/deployment/docker-compose-hub.yaml @@ -21,7 +21,6 @@ services: environment: - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/1 - - MONGO_URI=mongodb://mongo:27017/docsgpt - CACHE_REDIS_URL=redis://redis:6379/2 ports: - "7091:7091" @@ -31,7 +30,6 @@ services: - ../application/vectors:/app/vectors depends_on: - redis - - mongo worker: @@ -43,7 +41,6 @@ services: environment: - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/1 - - MONGO_URI=mongodb://mongo:27017/docsgpt - API_URL=http://backend:7091 - CACHE_REDIS_URL=redis://redis:6379/2 volumes: @@ -52,19 +49,8 @@ services: - ../application/vectors:/app/vectors depends_on: - redis - - mongo redis: image: redis:6-alpine ports: - 6379:6379 - - mongo: - image: mongo:6 - ports: - - 27017:27017 - volumes: - - mongodb_data_container:/data/db - -volumes: - mongodb_data_container: \ No newline at end of file diff --git a/deployment/docker-compose-local.yaml b/deployment/docker-compose-local.yaml index 77a82866..c46e4937 100644 --- a/deployment/docker-compose-local.yaml +++ b/deployment/docker-compose-local.yaml @@ -14,13 +14,3 @@ services: image: redis:6-alpine ports: - 6379:6379 - - mongo: - image: mongo:6 - ports: - - 27017:27017 - volumes: - - mongodb_data_container:/data/db - -volumes: - mongodb_data_container: diff --git a/deployment/docker-compose.yaml b/deployment/docker-compose.yaml index 6c99166d..fdecdc27 100644 --- a/deployment/docker-compose.yaml +++ b/deployment/docker-compose.yaml @@ -22,7 +22,6 @@ services: # Override URLs to use docker service names - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/1 - - MONGO_URI=mongodb://mongo:27017/docsgpt - CACHE_REDIS_URL=redis://redis:6379/2 ports: - "7091:7091" @@ -32,7 +31,6 @@ services: - ../application/vectors:/app/vectors depends_on: - redis - - mongo worker: user: root @@ -44,7 +42,6 @@ services: # Override URLs to use docker service names - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/1 - - MONGO_URI=mongodb://mongo:27017/docsgpt - API_URL=http://backend:7091 - CACHE_REDIS_URL=redis://redis:6379/2 volumes: @@ -53,19 +50,9 @@ services: - ../application/vectors:/app/vectors depends_on: - redis - - mongo redis: image: redis:6-alpine ports: - 6379:6379 - mongo: - image: mongo:6 - ports: - - 27017:27017 - volumes: - - mongodb_data_container:/data/db - -volumes: - mongodb_data_container: diff --git a/scripts/db/backfill.py b/scripts/db/backfill.py index 09a56c40..0c2df211 100644 --- a/scripts/db/backfill.py +++ b/scripts/db/backfill.py @@ -13,6 +13,12 @@ variables — ``USE_POSTGRES`` / ``READ_POSTGRES`` in ``.env`` are the only knobs operators need. This script discovers what's available from the :data:`BACKFILLERS` registry and runs whichever tables were asked for. +This script imports ``pymongo`` directly. ``pymongo`` is not part of the +base ``application/requirements.txt`` post-migration — install the optional +Mongo extras before running:: + + pip install -r application/requirements-mongo.txt + Usage:: python scripts/db/backfill.py # every registered table @@ -29,6 +35,7 @@ Exit codes: from __future__ import annotations import argparse +import io import json import logging import sys @@ -806,6 +813,188 @@ def _resolve_source_refs( return primary, extras +_FAISS_INDEX_FILES = ("index.faiss", "index.pkl") + + +def _rename_faiss_indexes( + *, + conn: Connection, + mongo_db: Any, # unused; kept for registry signature uniformity + batch_size: int, # unused; filesystem work, not DB batching + dry_run: bool, +) -> dict: + """Rename FAISS index dirs from legacy Mongo ObjectId to PG UUID. + + FAISS-specific: other vector stores (Qdrant, Elasticsearch, Chroma, + pgvector, Milvus, LanceDB, MongoDB Atlas Vector Search) key their + collections/indexes by the source identifier the application hands + them at query time — once the app starts emitting PG UUIDs post- + cutover, the next write re-keys the remote collection automatically + and any stale ObjectId-keyed collections are harmless orphans the + operator can clean up separately. FAISS, by contrast, stores each + index as a directory on disk (``indexes//index.faiss`` + + ``index.pkl``), so its on-disk layout must be physically renamed to + match the new PG UUIDs or the app will ``FileNotFoundError`` on the + first query after cutover. + + This backfiller is a no-op (log-only) unless ``settings.VECTOR_STORE`` + is ``"faiss"``. + + It reads ``sources.legacy_mongo_id -> sources.id`` from Postgres and, + for each row, renames ``indexes//`` to + ``indexes//`` via the storage abstraction so both local and + S3 backends are handled. Orphan directories (names matching no live + source) are left alone and only counted in the stats. Idempotent: + if the target dir already exists it is treated as a collision and + skipped. + """ + stats = { + "seen": 0, + "renamed": 0, + "skipped_missing": 0, + "skipped_collision": 0, + "other_vector_store": False, + } + + vector_store = (settings.VECTOR_STORE or "").strip().lower() + if vector_store != "faiss": + stats["other_vector_store"] = True + logger.info( + "rename_faiss_indexes: VECTOR_STORE=%s (not 'faiss'); " + "skipping FAISS-specific index directory rename. Other vector " + "stores re-key their collections on the first post-cutover write.", + vector_store or "", + ) + return stats + + from application.storage.storage_creator import StorageCreator + + storage = StorageCreator.get_storage() + storage_type = getattr(storage, "__class__", type(storage)).__name__ + base_dir = "indexes" + + rows = conn.execute( + text( + "SELECT id::text AS id, legacy_mongo_id " + "FROM sources " + "WHERE legacy_mongo_id IS NOT NULL" + ) + ).mappings().all() + + live_legacy_ids = {row["legacy_mongo_id"] for row in rows} + + for row in rows: + legacy_id = row["legacy_mongo_id"] + pg_uuid = row["id"] + stats["seen"] += 1 + + src_dir = f"{base_dir}/{legacy_id}" + dst_dir = f"{base_dir}/{pg_uuid}" + + if not storage.is_directory(src_dir): + stats["skipped_missing"] += 1 + continue + + if storage.is_directory(dst_dir): + logger.info( + "rename_faiss_indexes: target already exists, skipping: " + "%s -> %s", + src_dir, + dst_dir, + ) + stats["skipped_collision"] += 1 + continue + + if dry_run: + logger.info( + "rename_faiss_indexes: would rename %s -> %s", src_dir, dst_dir + ) + stats["renamed"] += 1 + continue + + # No directory-move primitive on BaseStorage. Copy each known + # FAISS file, then delete the source file(s). This works for + # both LocalStorage and S3Storage since both implement + # get_file/save_file/delete_file on BaseStorage. + moved_files: list[str] = [] + try: + for fname in _FAISS_INDEX_FILES: + src_path = f"{src_dir}/{fname}" + dst_path = f"{dst_dir}/{fname}" + if not storage.file_exists(src_path): + # Partial/legacy index dir. Copy whatever else is there + # via list_files so we don't silently drop data. + continue + data = storage.get_file(src_path).read() + storage.save_file(io.BytesIO(data), dst_path) + moved_files.append(src_path) + + # Pick up any auxiliary files that aren't in _FAISS_INDEX_FILES. + for rel_path in storage.list_files(src_dir): + # storage.list_files returns paths relative to the storage + # root (e.g. ``indexes//index.faiss``). Skip the + # two canonical files we already handled. + leaf = rel_path.rsplit("/", 1)[-1] + if leaf in _FAISS_INDEX_FILES: + continue + dst_extra = f"{dst_dir}/{leaf}" + data = storage.get_file(rel_path).read() + storage.save_file(io.BytesIO(data), dst_extra) + moved_files.append(rel_path) + + # Only remove the source dir once every copy succeeded. + storage.remove_directory(src_dir) + stats["renamed"] += 1 + logger.info( + "rename_faiss_indexes: renamed %s -> %s (%s)", + src_dir, + dst_dir, + storage_type, + ) + except Exception: + logger.exception( + "rename_faiss_indexes: failed to rename %s -> %s; " + "partial state may exist on %s. Files copied so far: %s", + src_dir, + dst_dir, + storage_type, + moved_files, + ) + raise + + # Count orphan dirs (in indexes/ but no matching live source) purely + # for operator visibility — leave them alone, as they may be + # previously-deleted sources unrelated to this migration. + try: + orphan_count = 0 + if storage.is_directory(base_dir): + seen_dirs: set[str] = set() + for rel_path in storage.list_files(base_dir): + parts = rel_path.split("/") + if len(parts) < 2: + continue + dir_name = parts[1] + if dir_name in seen_dirs: + continue + seen_dirs.add(dir_name) + if dir_name not in live_legacy_ids and not _is_uuid_str(dir_name): + orphan_count += 1 + if orphan_count: + logger.info( + "rename_faiss_indexes: %d orphan index director(y/ies) " + "under %s/ do not match any live source — left untouched.", + orphan_count, + base_dir, + ) + except Exception: + # Orphan counting is diagnostic only; never let it fail the backfill. + logger.debug( + "rename_faiss_indexes: orphan scan skipped", exc_info=True + ) + + return stats + + def _backfill_agents( *, conn: Connection, mongo_db: Any, batch_size: int, dry_run: bool, ) -> dict: @@ -2160,6 +2349,11 @@ BACKFILLERS: dict[str, BackfillFn] = { # Phase 2 (order: FK targets first) "agent_folders": _backfill_agent_folders, "sources": _backfill_sources, + # Filesystem rename of FAISS index dirs (legacy Mongo ObjectId -> PG UUID). + # No-op unless VECTOR_STORE=faiss. Runs after `sources` so the + # legacy_mongo_id -> id mapping is queryable, and before `agents` to keep + # the vector-store plumbing adjacent to the table it depends on. + "rename_faiss_indexes": _rename_faiss_indexes, "attachments": _backfill_attachments, # Workflows are migrated before agents because agents.workflow_id # FK-references the workflows table and the agents backfill resolves diff --git a/tests/agents/test_mcp_tool.py b/tests/agents/test_mcp_tool.py index c3d4d45b..53c7984a 100644 --- a/tests/agents/test_mcp_tool.py +++ b/tests/agents/test_mcp_tool.py @@ -9,7 +9,12 @@ import pytest # We must patch the dependencies BEFORE the module is first imported. @pytest.fixture(autouse=True) def _patch_mcp_globals(monkeypatch): - """Patch module-level MongoDB and cache to avoid real connections.""" + """Patch module-level cache to avoid real connections. + + MongoDB is no longer used at module level; DBTokenStorage now backs + onto the ``connector_sessions`` Postgres repository. The cache patch + is still required to avoid hitting real Redis. + """ import sys # If the module is already loaded, just patch attributes directly diff --git a/tests/agents/test_workflow_agent_coverage.py b/tests/agents/test_workflow_agent_coverage.py deleted file mode 100644 index f896e6d6..00000000 --- a/tests/agents/test_workflow_agent_coverage.py +++ /dev/null @@ -1,461 +0,0 @@ -"""Tests for WorkflowAgent - covering _parse_embedded_workflow, _load_from_database, -_save_workflow_run, _determine_run_status, _serialize_state, and gen flow.""" - -from datetime import datetime, timezone -from unittest.mock import MagicMock, patch - -import pytest - -from application.agents.workflows.schemas import ( - ExecutionStatus, - WorkflowGraph, -) - - -def _make_agent(**overrides): - """Create a WorkflowAgent with mocked base class dependencies.""" - defaults = { - "endpoint": "https://api.example.com", - "llm_name": "openai", - "model_id": "gpt-4", - "api_key": "test_key", - "user_api_key": None, - "prompt": "You are helpful.", - "chat_history": [], - "decoded_token": {"sub": "user1"}, - "attachments": [], - "json_schema": None, - } - defaults.update(overrides) - - with patch("application.agents.workflow_agent.log_activity", lambda **kw: lambda f: f): - from application.agents.workflow_agent import WorkflowAgent - agent = WorkflowAgent(**defaults) - return agent - - -class TestWorkflowAgentInit: - - @pytest.mark.unit - def test_sets_attributes(self): - agent = _make_agent(workflow_id="wf1", workflow_owner="owner1") - assert agent.workflow_id == "wf1" - assert agent.workflow_owner == "owner1" - assert agent._engine is None - - @pytest.mark.unit - def test_embedded_workflow(self): - wf_data = {"nodes": [], "edges": [], "name": "Test"} - agent = _make_agent(workflow=wf_data) - assert agent._workflow_data == wf_data - - -class TestParseEmbeddedWorkflow: - - @pytest.mark.unit - def test_parses_valid_workflow(self): - wf_data = { - "name": "Test Workflow", - "description": "A test", - "nodes": [ - {"id": "n1", "type": "start", "title": "Start", "data": {}, "position": {"x": 0, "y": 0}}, - {"id": "n2", "type": "end", "title": "End", "data": {}, "position": {"x": 100, "y": 0}}, - ], - "edges": [ - {"id": "e1", "source": "n1", "target": "n2", "sourceHandle": "out", "targetHandle": "in"}, - ], - } - agent = _make_agent(workflow=wf_data, workflow_id="wf1") - graph = agent._parse_embedded_workflow() - assert graph is not None - assert len(graph.nodes) == 2 - assert len(graph.edges) == 1 - assert graph.workflow.name == "Test Workflow" - - @pytest.mark.unit - def test_edge_source_id_alias(self): - wf_data = { - "nodes": [{"id": "n1", "type": "start", "data": {}}], - "edges": [{"id": "e1", "source_id": "n1", "target_id": "n2", "source_handle": "out", "target_handle": "in"}], - } - agent = _make_agent(workflow=wf_data) - graph = agent._parse_embedded_workflow() - assert graph is not None - assert graph.edges[0].source_id == "n1" - - @pytest.mark.unit - def test_invalid_data_returns_none(self): - agent = _make_agent(workflow={"nodes": [{"bad": "data"}], "edges": []}) - graph = agent._parse_embedded_workflow() - assert graph is None - - -class TestLoadWorkflowGraph: - - @pytest.mark.unit - def test_uses_embedded_when_available(self): - agent = _make_agent(workflow={"nodes": [], "edges": [], "name": "E"}) - agent._parse_embedded_workflow = MagicMock(return_value="parsed_graph") - result = agent._load_workflow_graph() - assert result == "parsed_graph" - - @pytest.mark.unit - def test_uses_database_when_workflow_id(self): - agent = _make_agent(workflow_id="wf1") - agent._load_from_database = MagicMock(return_value="db_graph") - result = agent._load_workflow_graph() - assert result == "db_graph" - - @pytest.mark.unit - def test_returns_none_when_nothing(self): - agent = _make_agent() - result = agent._load_workflow_graph() - assert result is None - - -class TestLoadFromDatabase: - - @pytest.mark.unit - def test_invalid_workflow_id_returns_none(self): - agent = _make_agent(workflow_id="invalid!") - result = agent._load_from_database() - assert result is None - - @pytest.mark.unit - def test_no_owner_returns_none(self): - agent = _make_agent(workflow_id="507f1f77bcf86cd799439011", decoded_token={}) - agent.workflow_owner = None - result = agent._load_from_database() - assert result is None - - @pytest.mark.unit - def test_uses_decoded_token_sub(self): - agent = _make_agent( - workflow_id="507f1f77bcf86cd799439011", - decoded_token={"sub": "user1"}, - ) - agent.workflow_owner = None - - mock_collection = MagicMock() - mock_collection.find_one.return_value = None - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_collection) - - with patch("application.agents.workflow_agent.MongoDB") as MockMongo, \ - patch("application.agents.workflow_agent.settings") as mock_settings: - mock_settings.MONGO_DB_NAME = "test_db" - MockMongo.get_client.return_value = {"test_db": mock_db} - result = agent._load_from_database() - assert result is None # workflow_doc not found - - @pytest.mark.unit - def test_successful_load(self): - agent = _make_agent( - workflow_id="507f1f77bcf86cd799439011", - workflow_owner="owner1", - ) - - mock_wf_coll = MagicMock() - mock_wf_coll.find_one.return_value = { - "_id": "507f1f77bcf86cd799439011", - "name": "Test WF", - "user": "owner1", - "current_graph_version": 1, - } - - mock_nodes_coll = MagicMock() - mock_nodes_coll.find.return_value = [ - {"id": "n1", "workflow_id": "507f1f77bcf86cd799439011", "type": "start", - "title": "Start", "position": {"x": 0, "y": 0}, "config": {}}, - ] - - mock_edges_coll = MagicMock() - mock_edges_coll.find.return_value = [] - - def getitem(name): - return {"workflows": mock_wf_coll, "workflow_nodes": mock_nodes_coll, "workflow_edges": mock_edges_coll}[name] - - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(side_effect=getitem) - - with patch("application.agents.workflow_agent.MongoDB") as MockMongo, \ - patch("application.agents.workflow_agent.settings") as mock_settings: - mock_settings.MONGO_DB_NAME = "test_db" - MockMongo.get_client.return_value = {"test_db": mock_db} - result = agent._load_from_database() - - assert result is not None - assert len(result.nodes) == 1 - - @pytest.mark.unit - def test_invalid_graph_version(self): - agent = _make_agent( - workflow_id="507f1f77bcf86cd799439011", - workflow_owner="owner1", - ) - - mock_wf_coll = MagicMock() - mock_wf_coll.find_one.return_value = { - "_id": "507f1f77bcf86cd799439011", - "name": "WF", - "user": "owner1", - "current_graph_version": "bad", - } - - mock_nodes_coll = MagicMock() - mock_nodes_coll.find.return_value = [] - mock_edges_coll = MagicMock() - mock_edges_coll.find.return_value = [] - - def getitem(name): - return {"workflows": mock_wf_coll, "workflow_nodes": mock_nodes_coll, "workflow_edges": mock_edges_coll}[name] - - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(side_effect=getitem) - - with patch("application.agents.workflow_agent.MongoDB") as MockMongo, \ - patch("application.agents.workflow_agent.settings") as mock_settings: - mock_settings.MONGO_DB_NAME = "test_db" - MockMongo.get_client.return_value = {"test_db": mock_db} - result = agent._load_from_database() - assert result is not None # Defaults to version 1 - - @pytest.mark.unit - def test_fallback_nodes_without_version(self): - """When graph_version=1 finds no nodes, falls back to nodes without version field.""" - agent = _make_agent( - workflow_id="507f1f77bcf86cd799439011", - workflow_owner="owner1", - ) - - mock_wf_coll = MagicMock() - mock_wf_coll.find_one.return_value = { - "_id": "507f1f77bcf86cd799439011", - "name": "WF", - "user": "owner1", - "current_graph_version": 1, - } - - call_count = [0] - def nodes_find(query): - call_count[0] += 1 - if call_count[0] == 1: - return [] # No versioned nodes - return [{"id": "n1", "workflow_id": "wf", "type": "start", - "title": "S", "position": {"x": 0, "y": 0}, "config": {}}] - - mock_nodes_coll = MagicMock() - mock_nodes_coll.find.side_effect = nodes_find - - edge_call = [0] - def edges_find(query): - edge_call[0] += 1 - if edge_call[0] == 1: - return [] - return [] - - mock_edges_coll = MagicMock() - mock_edges_coll.find.side_effect = edges_find - - def getitem(name): - return {"workflows": mock_wf_coll, "workflow_nodes": mock_nodes_coll, "workflow_edges": mock_edges_coll}[name] - - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(side_effect=getitem) - - with patch("application.agents.workflow_agent.MongoDB") as MockMongo, \ - patch("application.agents.workflow_agent.settings") as mock_settings: - mock_settings.MONGO_DB_NAME = "test_db" - MockMongo.get_client.return_value = {"test_db": mock_db} - result = agent._load_from_database() - assert result is not None - assert len(result.nodes) == 1 - - @pytest.mark.unit - def test_exception_returns_none(self): - agent = _make_agent( - workflow_id="507f1f77bcf86cd799439011", - workflow_owner="owner1", - ) - with patch("application.agents.workflow_agent.MongoDB") as MockMongo: - MockMongo.get_client.side_effect = Exception("db error") - result = agent._load_from_database() - assert result is None - - -class TestGenInner: - - @pytest.mark.unit - def test_no_graph_yields_error(self): - agent = _make_agent() - agent._load_workflow_graph = MagicMock(return_value=None) - events = list(agent._gen_inner("query", None)) - assert any(e.get("type") == "error" for e in events) - - @pytest.mark.unit - def test_successful_execution(self): - agent = _make_agent(workflow_id="wf1") - mock_graph = MagicMock(spec=WorkflowGraph) - agent._load_workflow_graph = MagicMock(return_value=mock_graph) - agent._save_workflow_run = MagicMock() - - mock_engine = MagicMock() - mock_engine.execute.return_value = iter([{"answer": "result"}]) - - with patch("application.agents.workflow_agent.WorkflowEngine", return_value=mock_engine): - events = list(agent._gen_inner("query", None)) - assert len(events) == 1 - agent._save_workflow_run.assert_called_once_with("query") - - -class TestSaveWorkflowRun: - - @pytest.mark.unit - def test_no_engine_returns_early(self): - agent = _make_agent() - agent._engine = None - agent._save_workflow_run("query") # Should not raise - - @pytest.mark.unit - def test_saves_to_mongo(self): - agent = _make_agent(workflow_id="wf1") - mock_engine = MagicMock() - mock_engine.state = {"query": "test"} - mock_engine.execution_log = [] - mock_engine.get_execution_summary.return_value = [] - agent._engine = mock_engine - - mock_collection = MagicMock() - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_collection) - - with patch("application.agents.workflow_agent.MongoDB") as MockMongo, \ - patch("application.agents.workflow_agent.settings") as mock_settings: - mock_settings.MONGO_DB_NAME = "test_db" - MockMongo.get_client.return_value = {"test_db": mock_db} - agent._save_workflow_run("query") - - mock_collection.insert_one.assert_called_once() - saved_doc = mock_collection.insert_one.call_args.args[0] - assert saved_doc["user"] == "user1" - assert saved_doc["user_id"] == "user1" - - @pytest.mark.unit - def test_dual_writes_when_mongo_insert_returns_id(self): - agent = _make_agent(workflow_id="507f1f77bcf86cd799439011") - mock_engine = MagicMock() - mock_engine.state = {"query": "test"} - mock_engine.execution_log = [] - mock_engine.get_execution_summary.return_value = [] - agent._engine = mock_engine - - insert_result = MagicMock() - insert_result.inserted_id = "507f1f77bcf86cd799439012" - mock_collection = MagicMock() - mock_collection.insert_one.return_value = insert_result - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_collection) - - with patch("application.agents.workflow_agent.MongoDB") as MockMongo, \ - patch("application.agents.workflow_agent.settings") as mock_settings, \ - patch("application.agents.workflow_agent.dual_write") as mock_dual_write: - mock_settings.MONGO_DB_NAME = "test_db" - MockMongo.get_client.return_value = {"test_db": mock_db} - agent._save_workflow_run("query") - - mock_dual_write.assert_called_once() - - @pytest.mark.unit - def test_exception_does_not_propagate(self): - agent = _make_agent(workflow_id="wf1") - mock_engine = MagicMock() - mock_engine.state = {} - mock_engine.execution_log = [] - mock_engine.get_execution_summary.return_value = [] - agent._engine = mock_engine - - with patch("application.agents.workflow_agent.MongoDB") as MockMongo: - MockMongo.get_client.side_effect = Exception("db fail") - agent._save_workflow_run("query") # Should not raise - - -class TestDetermineRunStatus: - - @pytest.mark.unit - def test_no_engine_returns_completed(self): - agent = _make_agent() - agent._engine = None - assert agent._determine_run_status() == ExecutionStatus.COMPLETED - - @pytest.mark.unit - def test_empty_log_returns_completed(self): - agent = _make_agent() - agent._engine = MagicMock() - agent._engine.execution_log = [] - assert agent._determine_run_status() == ExecutionStatus.COMPLETED - - @pytest.mark.unit - def test_failed_log_returns_failed(self): - agent = _make_agent() - agent._engine = MagicMock() - agent._engine.execution_log = [ - {"status": "completed"}, - {"status": "failed"}, - ] - assert agent._determine_run_status() == ExecutionStatus.FAILED - - @pytest.mark.unit - def test_all_completed_returns_completed(self): - agent = _make_agent() - agent._engine = MagicMock() - agent._engine.execution_log = [ - {"status": "completed"}, - {"status": "completed"}, - ] - assert agent._determine_run_status() == ExecutionStatus.COMPLETED - - -class TestSerializeState: - - @pytest.mark.unit - def test_serializes_primitives(self): - agent = _make_agent() - state = {"str": "hello", "int": 42, "float": 3.14, "bool": True, "none": None} - result = agent._serialize_state(state) - assert result == state - - @pytest.mark.unit - def test_serializes_nested_dict(self): - agent = _make_agent() - state = {"nested": {"key": "value"}} - result = agent._serialize_state(state) - assert result["nested"]["key"] == "value" - - @pytest.mark.unit - def test_serializes_list(self): - agent = _make_agent() - state = {"items": [1, 2, "three"]} - result = agent._serialize_state(state) - assert result["items"] == [1, 2, "three"] - - @pytest.mark.unit - def test_serializes_tuple(self): - agent = _make_agent() - state = {"tup": (1, 2)} - result = agent._serialize_state(state) - assert result["tup"] == [1, 2] - - @pytest.mark.unit - def test_serializes_datetime(self): - agent = _make_agent() - dt = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) - state = {"time": dt} - result = agent._serialize_state(state) - assert "2025-01-01" in result["time"] - - @pytest.mark.unit - def test_serializes_unknown_to_str(self): - agent = _make_agent() - state = {"obj": object()} - result = agent._serialize_state(state) - assert isinstance(result["obj"], str) diff --git a/tests/agents/test_workflow_schemas.py b/tests/agents/test_workflow_schemas.py index 21b3aedd..15f8d67d 100644 --- a/tests/agents/test_workflow_schemas.py +++ b/tests/agents/test_workflow_schemas.py @@ -1,7 +1,7 @@ +import uuid from datetime import datetime, timezone import pytest -from bson import ObjectId from pydantic import ValidationError from application.agents.workflows.schemas import ( @@ -220,7 +220,7 @@ class TestWorkflowEdgeCreate: class TestWorkflowEdge: @pytest.mark.unit def test_objectid_conversion(self): - oid = ObjectId() + oid = uuid.uuid4().hex e = WorkflowEdge( _id=oid, id="e1", @@ -304,7 +304,7 @@ class TestWorkflowNodeCreate: class TestWorkflowNode: @pytest.mark.unit def test_objectid_conversion(self): - oid = ObjectId() + oid = uuid.uuid4().hex n = WorkflowNode( _id=oid, id="n1", workflow_id="w1", type=NodeType.AGENT ) @@ -355,7 +355,7 @@ class TestWorkflowCreate: class TestWorkflow: @pytest.mark.unit def test_objectid_conversion(self): - oid = ObjectId() + oid = uuid.uuid4().hex w = Workflow(_id=oid) assert w.id == str(oid) @@ -526,7 +526,7 @@ class TestWorkflowRun: @pytest.mark.unit def test_objectid_conversion(self): - oid = ObjectId() + oid = uuid.uuid4().hex r = WorkflowRun(_id=oid, workflow_id="w1") assert r.id == str(oid) diff --git a/tests/agents/tools/test_internal_search.py b/tests/agents/tools/test_internal_search.py index d48698ef..7dd2b285 100644 --- a/tests/agents/tools/test_internal_search.py +++ b/tests/agents/tools/test_internal_search.py @@ -5,8 +5,7 @@ directory structure loading), build helpers, add_internal_search_tool, sources_have_directory_structure. """ -import json -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest @@ -298,29 +297,6 @@ class TestCountFiles: @pytest.mark.unit class TestGetDirectoryStructure: - def test_loads_from_mongo(self): - tool = InternalSearchTool({ - "source": {"active_docs": ["507f1f77bcf86cd799439011"]}, - }) - - mock_collection = MagicMock() - mock_collection.find_one.return_value = { - "_id": "507f1f77bcf86cd799439011", - "name": "test_source", - "directory_structure": {"src": {"main.py": {}}}, - } - - with patch("application.core.mongo_db.MongoDB") as mock_mongo: - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_collection) - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value=mock_db) - mock_mongo.get_client.return_value = mock_client - - result = tool._get_directory_structure() - assert result is not None - assert "src" in result - def test_returns_none_without_active_docs(self): tool = InternalSearchTool({"source": {}}) result = tool._get_directory_structure() @@ -335,78 +311,6 @@ class TestGetDirectoryStructure: result = tool._get_directory_structure() assert result == {"cached": True} - def test_handles_json_string_structure(self): - tool = InternalSearchTool({ - "source": {"active_docs": ["507f1f77bcf86cd799439011"]}, - }) - - mock_collection = MagicMock() - mock_collection.find_one.return_value = { - "_id": "507f1f77bcf86cd799439011", - "name": "test_source", - "directory_structure": json.dumps({"src": {"app.py": {}}}), - } - - with patch("application.core.mongo_db.MongoDB") as mock_mongo: - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_collection) - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value=mock_db) - mock_mongo.get_client.return_value = mock_client - - result = tool._get_directory_structure() - assert result is not None - assert "src" in result - - def test_handles_string_active_docs(self): - tool = InternalSearchTool({ - "source": {"active_docs": "507f1f77bcf86cd799439011"}, - }) - - mock_collection = MagicMock() - mock_collection.find_one.return_value = { - "_id": "507f1f77bcf86cd799439011", - "directory_structure": {"dir": {}}, - } - - with patch("application.core.mongo_db.MongoDB") as mock_mongo: - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_collection) - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value=mock_db) - mock_mongo.get_client.return_value = mock_client - - result = tool._get_directory_structure() - assert result is not None - - def test_merges_multiple_sources(self): - tool = InternalSearchTool({ - "source": { - "active_docs": [ - "507f1f77bcf86cd799439011", - "507f1f77bcf86cd799439012", - ], - }, - }) - - mock_collection = MagicMock() - mock_collection.find_one.side_effect = [ - {"name": "src1", "directory_structure": {"a": {}}}, - {"name": "src2", "directory_structure": {"b": {}}}, - ] - - with patch("application.core.mongo_db.MongoDB") as mock_mongo: - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_collection) - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value=mock_db) - mock_mongo.get_client.return_value = mock_client - - result = tool._get_directory_structure() - assert "src1" in result - assert "src2" in result - - # ===================================================================== # Metadata # ===================================================================== @@ -532,65 +436,3 @@ class TestSourcesHaveDirectoryStructure: def test_no_active_docs(self): assert sources_have_directory_structure({}) is False - - def test_with_directory_structure(self): - mock_collection = MagicMock() - mock_collection.find_one.return_value = { - "directory_structure": {"src": {}}, - } - - with patch("application.core.mongo_db.MongoDB") as mock_mongo: - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_collection) - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value=mock_db) - mock_mongo.get_client.return_value = mock_client - - result = sources_have_directory_structure( - {"active_docs": ["507f1f77bcf86cd799439011"]} - ) - assert result is True - - def test_without_directory_structure(self): - mock_collection = MagicMock() - mock_collection.find_one.return_value = {"directory_structure": None} - - with patch("application.core.mongo_db.MongoDB") as mock_mongo: - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_collection) - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value=mock_db) - mock_mongo.get_client.return_value = mock_client - - result = sources_have_directory_structure( - {"active_docs": ["507f1f77bcf86cd799439011"]} - ) - assert result is False - - def test_handles_exception_gracefully(self): - with patch( - "application.core.mongo_db.MongoDB.get_client", - side_effect=Exception("DB down"), - ): - result = sources_have_directory_structure( - {"active_docs": ["507f1f77bcf86cd799439011"]} - ) - assert result is False - - def test_string_active_docs(self): - mock_collection = MagicMock() - mock_collection.find_one.return_value = { - "directory_structure": {"a": {}}, - } - - with patch("application.core.mongo_db.MongoDB") as mock_mongo: - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_collection) - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value=mock_db) - mock_mongo.get_client.return_value = mock_client - - result = sources_have_directory_structure( - {"active_docs": "507f1f77bcf86cd799439011"} - ) - assert result is True diff --git a/tests/agents/tools/test_mcp_tool.py b/tests/agents/tools/test_mcp_tool.py index 4e1beb82..5a45850d 100644 --- a/tests/agents/tools/test_mcp_tool.py +++ b/tests/agents/tools/test_mcp_tool.py @@ -17,7 +17,12 @@ import pytest @pytest.fixture(autouse=True) def _patch_mcp_globals(monkeypatch): - """Patch module-level MongoDB and cache to avoid real connections.""" + """Patch module-level cache to avoid real connections. + + MongoDB is no longer used at module level; DBTokenStorage now backs + onto the ``connector_sessions`` Postgres repository. The cache patch + is still required to avoid hitting real Redis. + """ import sys if "application.agents.tools.mcp_tool" in sys.modules: diff --git a/tests/api/answer/routes/test_stream.py b/tests/api/answer/routes/test_stream.py index f025bfd0..9eb3254a 100644 --- a/tests/api/answer/routes/test_stream.py +++ b/tests/api/answer/routes/test_stream.py @@ -1,10 +1,10 @@ """Tests for application/api/answer/routes/stream.py""" import json +import uuid from unittest.mock import MagicMock, patch import pytest -from bson import ObjectId @pytest.fixture @@ -15,9 +15,9 @@ def mock_stream_processor(): ) as MockProcessor: processor = MagicMock() processor.decoded_token = {"sub": "test_user"} - processor.conversation_id = str(ObjectId()) + processor.conversation_id = uuid.uuid4().hex processor.agent_config = {} - processor.agent_id = str(ObjectId()) + processor.agent_id = uuid.uuid4().hex processor.is_shared_usage = False processor.shared_token = None processor.model_id = "gpt-4" @@ -166,7 +166,7 @@ class TestStreamResourcePost: def fake_stream(*args, **kwargs): yield f'data: {json.dumps({"type": "end"})}\n\n' - conv_id = str(ObjectId()) + conv_id = uuid.uuid4().hex with patch( "application.api.answer.routes.stream.StreamResource.validate_request", return_value=None, diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 60223a5d..f0e1f408 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -1,7 +1,8 @@ """API-specific test fixtures.""" +import uuid + import pytest -from bson import ObjectId @pytest.fixture @@ -27,7 +28,7 @@ def mock_request_token(monkeypatch, decoded_token): @pytest.fixture def sample_conversation(): return { - "_id": ObjectId(), + "_id": uuid.uuid4().hex, "user": "test_user", "name": "Test Conversation", "queries": [ @@ -43,7 +44,7 @@ def sample_conversation(): @pytest.fixture def sample_prompt(): return { - "_id": ObjectId(), + "_id": uuid.uuid4().hex, "user": "test_user", "name": "Helpful Assistant", "content": "You are a helpful assistant that provides clear and concise answers.", @@ -54,7 +55,7 @@ def sample_prompt(): @pytest.fixture def sample_agent(): return { - "_id": ObjectId(), + "_id": uuid.uuid4().hex, "user": "test_user", "name": "Test Agent", "type": "classic", diff --git a/tests/api/user/sources/test_source_routes.py b/tests/api/user/sources/test_source_routes.py index eed326aa..d22c1f83 100644 --- a/tests/api/user/sources/test_source_routes.py +++ b/tests/api/user/sources/test_source_routes.py @@ -360,71 +360,6 @@ class TestPaginatedSources: assert entry["type"] == "s3" -# --------------------------------------------------------------------------- -# DeleteByIds (/api/delete_by_ids) -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestDeleteByIds: - - def test_returns_400_when_path_missing(self, app): - from application.api.user.sources.routes import DeleteByIds - - with app.test_request_context("/api/delete_by_ids"): - response = DeleteByIds().get() - - assert _status(response) == 400 - assert "Missing" in _json(response)["message"] - - def test_returns_200_on_successful_delete(self, app): - from application.api.user.sources.routes import DeleteByIds - - mock_collection = Mock() - mock_collection.delete_index.return_value = True - - with patch( - "application.api.user.sources.routes.sources_collection", - mock_collection, - ): - with app.test_request_context("/api/delete_by_ids?path=id1,id2"): - response = DeleteByIds().get() - - assert _status(response) == 200 - assert _json(response)["success"] is True - mock_collection.delete_index.assert_called_once_with(ids="id1,id2") - - def test_returns_400_when_delete_returns_false(self, app): - from application.api.user.sources.routes import DeleteByIds - - mock_collection = Mock() - mock_collection.delete_index.return_value = False - - with patch( - "application.api.user.sources.routes.sources_collection", - mock_collection, - ): - with app.test_request_context("/api/delete_by_ids?path=id1"): - response = DeleteByIds().get() - - assert _status(response) == 400 - - def test_returns_400_on_exception(self, app): - from application.api.user.sources.routes import DeleteByIds - - mock_collection = Mock() - mock_collection.delete_index.side_effect = Exception("fail") - - with patch( - "application.api.user.sources.routes.sources_collection", - mock_collection, - ): - with app.test_request_context("/api/delete_by_ids?path=id1"): - response = DeleteByIds().get() - - assert _status(response) == 400 - - # --------------------------------------------------------------------------- # DeleteOldIndexes (/api/delete_old) # --------------------------------------------------------------------------- diff --git a/tests/api/user/test_agents_routes.py b/tests/api/user/test_agents_routes.py index 4dba74ca..12701ca9 100644 --- a/tests/api/user/test_agents_routes.py +++ b/tests/api/user/test_agents_routes.py @@ -4,7 +4,6 @@ import uuid from unittest.mock import Mock, patch import pytest -from bson import DBRef, ObjectId from flask import Flask @@ -53,7 +52,7 @@ class TestNormalizeWorkflowReference: def test_returns_plain_string_value(self): from application.api.user.agents.routes import normalize_workflow_reference - oid = str(ObjectId()) + oid = str(uuid.uuid4().hex) assert normalize_workflow_reference(oid) == oid def test_parses_json_string_value(self): @@ -120,7 +119,7 @@ class TestValidateWorkflowAccess: mock_wf_col = Mock() mock_wf_col.find_one.return_value = None - oid = str(ObjectId()) + oid = str(uuid.uuid4().hex) with app.app_context(): with patch( @@ -133,8 +132,8 @@ class TestValidateWorkflowAccess: from application.api.user.agents.routes import validate_workflow_access mock_wf_col = Mock() - mock_wf_col.find_one.return_value = {"_id": ObjectId(), "user": "user1"} - oid = str(ObjectId()) + mock_wf_col.find_one.return_value = {"_id": uuid.uuid4().hex, "user": "user1"} + oid = str(uuid.uuid4().hex) with app.app_context(): with patch( @@ -232,7 +231,7 @@ class TestBuildAgentDocument: def test_source_and_sources_passed_through(self): from application.api.user.agents.routes import build_agent_document - source_ref = DBRef("sources", ObjectId()) + source_ref = uuid.uuid4().hex # TODO: was DBRef("sources", uuid.uuid4().hex) doc = build_agent_document( {"name": "A", "status": "draft"}, "user1", @@ -277,7 +276,7 @@ class TestGetAgent: def test_returns_404_agent_not_found(self, app): from application.api.user.agents.routes import GetAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_col = Mock() mock_col.find_one.return_value = None @@ -294,7 +293,7 @@ class TestGetAgent: def test_returns_agent_data_on_success(self, app): from application.api.user.agents.routes import GetAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex key = str(uuid.uuid4()) mock_col = Mock() mock_col.find_one.return_value = { @@ -341,7 +340,7 @@ class TestGetAgent: with patch( "application.api.user.agents.routes.agents_collection", mock_col ): - with app.test_request_context(f"/api/get_agent?id={ObjectId()}"): + with app.test_request_context(f"/api/get_agent?id={uuid.uuid4().hex}"): from flask import request request.decoded_token = {"sub": "user1"} @@ -365,7 +364,7 @@ class TestGetAgents: def test_returns_agents_list(self, app): from application.api.user.agents.routes import GetAgents - agent_id = ObjectId() + agent_id = uuid.uuid4().hex key = str(uuid.uuid4()) mock_agents_col = Mock() mock_agents_col.find.return_value = [ @@ -415,7 +414,7 @@ class TestGetAgents: # Agent without source/retriever and not workflow type -> filtered out mock_agents_col.find.return_value = [ { - "_id": ObjectId(), + "_id": uuid.uuid4().hex, "user": "user1", "name": "BadAgent", "agent_type": "classic", @@ -451,7 +450,7 @@ class TestGetAgents: def test_includes_workflow_agent_without_source(self, app): from application.api.user.agents.routes import GetAgents - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.find.return_value = [ { @@ -540,7 +539,7 @@ class TestCreateAgent: def test_creates_draft_agent_success(self, app): from application.api.user.agents.routes import CreateAgent - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id) @@ -573,11 +572,11 @@ class TestCreateAgent: def test_creates_published_classic_agent(self, app): from application.api.user.agents.routes import CreateAgent - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id) mock_handle_img = Mock(return_value=("img.png", None)) - source_id = str(ObjectId()) + source_id = str(uuid.uuid4().hex) with patch( "application.api.user.agents.routes.agents_collection", mock_agents_col @@ -638,13 +637,13 @@ class TestCreateAgent: def test_creates_workflow_agent(self, app): from application.api.user.agents.routes import CreateAgent - inserted_id = ObjectId() - wf_id = str(ObjectId()) + inserted_id = uuid.uuid4().hex + wf_id = str(uuid.uuid4().hex) mock_agents_col = Mock() mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id) mock_handle_img = Mock(return_value=("", None)) mock_wf_col = Mock() - mock_wf_col.find_one.return_value = {"_id": ObjectId(wf_id), "user": "user1"} + mock_wf_col.find_one.return_value = {"_id": wf_id, "user": "user1"} with patch( "application.api.user.agents.routes.agents_collection", mock_agents_col @@ -724,7 +723,7 @@ class TestCreateAgent: mock_handle_img = Mock(return_value=("", None)) mock_folders = Mock() mock_folders.find_one.return_value = None - folder_id = str(ObjectId()) + folder_id = str(uuid.uuid4().hex) with patch( "application.api.user.agents.routes.handle_image_upload", mock_handle_img @@ -774,7 +773,7 @@ class TestCreateAgent: def test_form_data_with_json_fields(self, app): from application.api.user.agents.routes import CreateAgent - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id) mock_handle_img = Mock(return_value=("", None)) @@ -807,7 +806,7 @@ class TestCreateAgent: def test_form_data_invalid_json_tools(self, app): from application.api.user.agents.routes import CreateAgent - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id) mock_handle_img = Mock(return_value=("", None)) @@ -838,8 +837,8 @@ class TestCreateAgent: def test_create_with_sources_list(self, app): from application.api.user.agents.routes import CreateAgent - inserted_id = ObjectId() - src_id = str(ObjectId()) + inserted_id = uuid.uuid4().hex + src_id = str(uuid.uuid4().hex) mock_agents_col = Mock() mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id) mock_handle_img = Mock(return_value=("", None)) @@ -902,7 +901,7 @@ class TestUpdateAgent: def _make_existing_agent(self, agent_id=None): return { - "_id": agent_id or ObjectId(), + "_id": agent_id or uuid.uuid4().hex, "user": "user1", "name": "Existing Agent", "description": "existing desc", @@ -946,7 +945,7 @@ class TestUpdateAgent: def test_returns_404_agent_not_found(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = str(ObjectId()) + agent_id = str(uuid.uuid4().hex) mock_col = Mock() mock_col.find_one.return_value = None mock_handle_img = Mock(return_value=("", None)) @@ -970,7 +969,7 @@ class TestUpdateAgent: def test_updates_agent_name_success(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_repo = Mock() @@ -1009,7 +1008,7 @@ class TestUpdateAgent: def test_returns_400_invalid_status(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1034,7 +1033,7 @@ class TestUpdateAgent: def test_returns_400_negative_chunks(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1059,7 +1058,7 @@ class TestUpdateAgent: def test_returns_400_tools_not_list(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1084,7 +1083,7 @@ class TestUpdateAgent: def test_returns_400_no_update_data(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1109,7 +1108,7 @@ class TestUpdateAgent: def test_limited_token_mode_without_limit(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1134,7 +1133,7 @@ class TestUpdateAgent: def test_limited_request_mode_without_limit(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1159,7 +1158,7 @@ class TestUpdateAgent: def test_token_limit_without_mode(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1184,7 +1183,7 @@ class TestUpdateAgent: def test_request_limit_without_mode(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1209,7 +1208,7 @@ class TestUpdateAgent: def test_source_with_invalid_oid(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1234,7 +1233,7 @@ class TestUpdateAgent: def test_sources_list_with_invalid_oid(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1259,7 +1258,7 @@ class TestUpdateAgent: def test_update_source_to_default(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1285,7 +1284,7 @@ class TestUpdateAgent: def test_update_empty_source_on_draft(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) existing["status"] = "draft" existing["key"] = "" @@ -1313,7 +1312,7 @@ class TestUpdateAgent: def test_update_empty_source_on_published_fails(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) # Published agent with only "default" source existing["source"] = "default" @@ -1340,7 +1339,7 @@ class TestUpdateAgent: def test_update_chunks_empty_defaults_to_2(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1368,7 +1367,7 @@ class TestUpdateAgent: def test_update_invalid_chunks_value(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1393,7 +1392,7 @@ class TestUpdateAgent: def test_update_matched_but_not_modified(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1420,7 +1419,7 @@ class TestUpdateAgent: def test_update_matched_zero_returns_404(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1446,7 +1445,7 @@ class TestUpdateAgent: def test_publish_draft_generates_key(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) existing["status"] = "draft" existing["key"] = "" @@ -1476,7 +1475,7 @@ class TestUpdateAgent: def test_publish_missing_required_fields(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = { "_id": agent_id, "user": "user1", @@ -1513,7 +1512,7 @@ class TestUpdateAgent: def test_db_update_exception(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1539,7 +1538,7 @@ class TestUpdateAgent: def test_update_json_schema_valid(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1571,7 +1570,7 @@ class TestUpdateAgent: from application.api.user.agents.routes import UpdateAgent from application.core.json_schema_utils import JsonSchemaValidationError - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1602,7 +1601,7 @@ class TestUpdateAgent: def test_update_json_schema_none(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1628,15 +1627,15 @@ class TestUpdateAgent: def test_update_folder_id_valid(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() - folder_id = str(ObjectId()) + agent_id = uuid.uuid4().hex + folder_id = str(uuid.uuid4().hex) existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1) mock_handle_img = Mock(return_value=("", None)) mock_folders = Mock() - mock_folders.find_one.return_value = {"_id": ObjectId(folder_id), "user": "user1"} + mock_folders.find_one.return_value = {"_id": folder_id, "user": "user1"} with patch( "application.api.user.agents.routes.agents_collection", mock_col @@ -1659,7 +1658,7 @@ class TestUpdateAgent: def test_update_folder_id_invalid_format(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1684,8 +1683,8 @@ class TestUpdateAgent: def test_update_folder_not_found(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() - folder_id = str(ObjectId()) + agent_id = uuid.uuid4().hex + folder_id = str(uuid.uuid4().hex) existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1714,7 +1713,7 @@ class TestUpdateAgent: def test_update_folder_id_empty_sets_none(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1742,7 +1741,7 @@ class TestUpdateAgent: def test_empty_name_field_rejected(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1767,8 +1766,8 @@ class TestUpdateAgent: def test_update_workflow_field(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() - wf_id = str(ObjectId()) + agent_id = uuid.uuid4().hex + wf_id = str(uuid.uuid4().hex) existing = self._make_existing_agent(agent_id) existing["agent_type"] = "workflow" existing["status"] = "draft" @@ -1777,7 +1776,7 @@ class TestUpdateAgent: mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1) mock_handle_img = Mock(return_value=("", None)) mock_wf_col = Mock() - mock_wf_col.find_one.return_value = {"_id": ObjectId(wf_id), "user": "user1"} + mock_wf_col.find_one.return_value = {"_id": wf_id, "user": "user1"} with patch( "application.api.user.agents.routes.agents_collection", mock_col @@ -1800,7 +1799,7 @@ class TestUpdateAgent: def test_publish_workflow_without_workflow_field(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = { "_id": agent_id, "user": "user1", @@ -1833,8 +1832,8 @@ class TestUpdateAgent: def test_form_data_json_parse_error(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = str(ObjectId()) - existing = self._make_existing_agent(ObjectId(agent_id)) + agent_id = str(uuid.uuid4().hex) + existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing mock_handle_img = Mock(return_value=("", None)) @@ -1859,7 +1858,7 @@ class TestUpdateAgent: def test_limited_token_mode_string_true(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1888,8 +1887,8 @@ class TestUpdateAgent: def test_update_sources_with_default(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() - src_id = str(ObjectId()) + agent_id = uuid.uuid4().hex + src_id = str(uuid.uuid4().hex) existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -1945,7 +1944,7 @@ class TestDeleteAgent: with patch( "application.api.user.agents.routes.agents_collection", mock_col ): - with app.test_request_context(f"/api/delete_agent?id={ObjectId()}"): + with app.test_request_context(f"/api/delete_agent?id={uuid.uuid4().hex}"): from flask import request request.decoded_token = {"sub": "user1"} @@ -1955,7 +1954,7 @@ class TestDeleteAgent: def test_deletes_classic_agent_success(self, app): from application.api.user.agents.routes import DeleteAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_col = Mock() mock_repo = Mock() mock_col.find_one_and_delete.return_value = { @@ -1985,8 +1984,8 @@ class TestDeleteAgent: def test_deletes_workflow_agent_cleans_up(self, app): from application.api.user.agents.routes import DeleteAgent - agent_id = ObjectId() - wf_id = str(ObjectId()) + agent_id = uuid.uuid4().hex + wf_id = str(uuid.uuid4().hex) mock_agents_col = Mock() mock_agents_col.find_one_and_delete.return_value = { "_id": agent_id, @@ -1995,7 +1994,7 @@ class TestDeleteAgent: "workflow": wf_id, } mock_wf_col = Mock() - mock_wf_col.find_one.return_value = {"_id": ObjectId(wf_id), "user": "user1"} + mock_wf_col.find_one.return_value = {"_id": wf_id, "user": "user1"} mock_nodes_col = Mock() mock_edges_col = Mock() @@ -2021,8 +2020,8 @@ class TestDeleteAgent: def test_deletes_workflow_agent_non_owned_skips_cleanup(self, app): from application.api.user.agents.routes import DeleteAgent - agent_id = ObjectId() - wf_id = str(ObjectId()) + agent_id = uuid.uuid4().hex + wf_id = str(uuid.uuid4().hex) mock_agents_col = Mock() mock_agents_col.find_one_and_delete.return_value = { "_id": agent_id, @@ -2061,7 +2060,7 @@ class TestDeleteAgent: with patch( "application.api.user.agents.routes.agents_collection", mock_col ): - with app.test_request_context(f"/api/delete_agent?id={ObjectId()}"): + with app.test_request_context(f"/api/delete_agent?id={uuid.uuid4().hex}"): from flask import request request.decoded_token = {"sub": "user1"} @@ -2106,7 +2105,7 @@ class TestPinnedAgents: def test_returns_pinned_agents(self, app): from application.api.user.agents.routes import PinnedAgents - agent_id = ObjectId() + agent_id = uuid.uuid4().hex key = str(uuid.uuid4()) mock_ensure = Mock( return_value={ @@ -2152,7 +2151,7 @@ class TestPinnedAgents: def test_cleans_up_stale_pinned_ids(self, app): from application.api.user.agents.routes import PinnedAgents - stale_id = str(ObjectId()) + stale_id = str(uuid.uuid4().hex) mock_ensure = Mock( return_value={ "user_id": "user1", @@ -2200,7 +2199,7 @@ class TestGetTemplateAgents: def test_returns_template_agents(self, app): from application.api.user.agents.routes import GetTemplateAgents - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_col = Mock() mock_col.find.return_value = [ { @@ -2263,7 +2262,7 @@ class TestAdoptAgent: mock_col = Mock() mock_col.find_one.return_value = None - agent_id = str(ObjectId()) + agent_id = str(uuid.uuid4().hex) with patch( "application.api.user.agents.routes.agents_collection", mock_col @@ -2278,8 +2277,8 @@ class TestAdoptAgent: def test_adopts_agent_success(self, app): from application.api.user.agents.routes import AdoptAgent - agent_id = ObjectId() - new_id = ObjectId() + agent_id = uuid.uuid4().hex + new_id = uuid.uuid4().hex mock_col = Mock() mock_col.find_one.return_value = { "_id": agent_id, @@ -2316,7 +2315,7 @@ class TestAdoptAgent: with patch( "application.api.user.agents.routes.agents_collection", mock_col ): - with app.test_request_context(f"/api/adopt_agent?id={ObjectId()}"): + with app.test_request_context(f"/api/adopt_agent?id={uuid.uuid4().hex}"): from flask import request request.decoded_token = {"sub": "user1"} @@ -2356,7 +2355,7 @@ class TestPinAgent: with patch( "application.api.user.agents.routes.agents_collection", mock_col ): - with app.test_request_context(f"/api/pin_agent?id={ObjectId()}"): + with app.test_request_context(f"/api/pin_agent?id={uuid.uuid4().hex}"): from flask import request request.decoded_token = {"sub": "user1"} @@ -2366,9 +2365,9 @@ class TestPinAgent: def test_pins_agent(self, app): from application.api.user.agents.routes import PinAgent - agent_id = str(ObjectId()) + agent_id = str(uuid.uuid4().hex) mock_col = Mock() - mock_col.find_one.return_value = {"_id": ObjectId(agent_id)} + mock_col.find_one.return_value = {"_id": agent_id} mock_ensure = Mock( return_value={ @@ -2396,9 +2395,9 @@ class TestPinAgent: def test_unpins_agent(self, app): from application.api.user.agents.routes import PinAgent - agent_id = str(ObjectId()) + agent_id = str(uuid.uuid4().hex) mock_col = Mock() - mock_col.find_one.return_value = {"_id": ObjectId(agent_id)} + mock_col.find_one.return_value = {"_id": agent_id} mock_ensure = Mock( return_value={ @@ -2432,7 +2431,7 @@ class TestPinAgent: with patch( "application.api.user.agents.routes.agents_collection", mock_col ): - with app.test_request_context(f"/api/pin_agent?id={ObjectId()}"): + with app.test_request_context(f"/api/pin_agent?id={uuid.uuid4().hex}"): from flask import request request.decoded_token = {"sub": "user1"} @@ -2473,7 +2472,7 @@ class TestRemoveSharedAgent: "application.api.user.agents.routes.agents_collection", mock_col ): with app.test_request_context( - f"/api/remove_shared_agent?id={ObjectId()}" + f"/api/remove_shared_agent?id={uuid.uuid4().hex}" ): from flask import request @@ -2484,10 +2483,10 @@ class TestRemoveSharedAgent: def test_removes_shared_agent_success(self, app): from application.api.user.agents.routes import RemoveSharedAgent - agent_id = str(ObjectId()) + agent_id = str(uuid.uuid4().hex) mock_col = Mock() mock_col.find_one.return_value = { - "_id": ObjectId(agent_id), + "_id": agent_id, "shared_publicly": True, } @@ -2527,7 +2526,7 @@ class TestRemoveSharedAgent: "application.api.user.agents.routes.agents_collection", mock_col ): with app.test_request_context( - f"/api/remove_shared_agent?id={ObjectId()}" + f"/api/remove_shared_agent?id={uuid.uuid4().hex}" ): from flask import request @@ -2549,7 +2548,7 @@ class TestCreateAgentFormDataEdgeCases: """Lines 474-475: invalid JSON for 'sources' falls back to [].""" from application.api.user.agents.routes import CreateAgent - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id) mock_handle_img = Mock(return_value=("", None)) @@ -2580,7 +2579,7 @@ class TestCreateAgentFormDataEdgeCases: """Lines 479-480: invalid JSON for 'json_schema' falls back to None.""" from application.api.user.agents.routes import CreateAgent - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id) mock_handle_img = Mock(return_value=("", None)) @@ -2611,7 +2610,7 @@ class TestCreateAgentFormDataEdgeCases: """Lines 484-485: invalid JSON for 'models' falls back to [].""" from application.api.user.agents.routes import CreateAgent - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id) mock_handle_img = Mock(return_value=("", None)) @@ -2642,7 +2641,7 @@ class TestCreateAgentFormDataEdgeCases: """Line 511-517: unknown agent_type not in AGENT_TYPE_SCHEMAS.""" from application.api.user.agents.routes import CreateAgent - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id) mock_handle_img = Mock(return_value=("", None)) @@ -2674,7 +2673,7 @@ class TestUpdateAgentFormDataEdgeCases: def _make_existing_agent(self, agent_id=None): return { - "_id": agent_id or ObjectId(), + "_id": agent_id or uuid.uuid4().hex, "user": "user1", "name": "Existing Agent", "description": "existing desc", @@ -2691,7 +2690,7 @@ class TestUpdateAgentFormDataEdgeCases: """Line 699: form.to_dict() branch in UpdateAgent.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -2724,7 +2723,7 @@ class TestUpdateAgentFormDataEdgeCases: """Lines 712-713: invalid JSON for sources in form data.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -2752,7 +2751,7 @@ class TestUpdateAgentFormDataEdgeCases: """Lines 712-713: invalid JSON for models in form data.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -2780,7 +2779,7 @@ class TestUpdateAgentFormDataEdgeCases: """Line 715-716: empty json_schema string sets None.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -2808,7 +2807,7 @@ class TestUpdateAgentFormDataEdgeCases: """Line 730: exception when finding agent in DB.""" from application.api.user.agents.routes import UpdateAgent - agent_id = str(ObjectId()) + agent_id = str(uuid.uuid4().hex) mock_col = Mock() mock_col.find_one.side_effect = Exception("DB connection lost") mock_handle_img = Mock(return_value=("", None)) @@ -2834,7 +2833,7 @@ class TestUpdateAgentFormDataEdgeCases: """Line 746: image upload error is returned directly.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -2861,7 +2860,7 @@ class TestUpdateAgentFormDataEdgeCases: """Lines 904-907: limited_request_mode as string 'True'.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -2891,7 +2890,7 @@ class TestUpdateAgentFormDataEdgeCases: """Line 1031: workflow_id is missing when publishing workflow.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = { "_id": agent_id, "user": "user1", @@ -2926,7 +2925,7 @@ class TestUpdateAgentFormDataEdgeCases: """Line 1032-1033: workflow_id is present but not valid ObjectId.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = { "_id": agent_id, "user": "user1", @@ -2960,8 +2959,8 @@ class TestUpdateAgentFormDataEdgeCases: """Lines 1035-1039: workflow exists but not found in DB.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() - wf_id = str(ObjectId()) + agent_id = uuid.uuid4().hex + wf_id = str(uuid.uuid4().hex) existing = { "_id": agent_id, "user": "user1", @@ -3000,7 +2999,7 @@ class TestUpdateAgentFormDataEdgeCases: """Line 1000-1001: image_url is truthy, added to update_fields.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -3028,7 +3027,7 @@ class TestUpdateAgentFormDataEdgeCases: """Line 1134-1136: newly_generated_key is included in response.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) existing["status"] = "draft" existing["key"] = "" @@ -3065,7 +3064,7 @@ class TestDeleteAgentWorkflowEdgeCases: """Lines 1179-1181: invalid workflow id skips cleanup.""" from application.api.user.agents.routes import DeleteAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_col = Mock() mock_col.find_one_and_delete.return_value = { "_id": agent_id, @@ -3111,7 +3110,7 @@ class TestCreateAgentEmptyAgentType: from application.api.user.agents.routes import CreateAgent mock_col = Mock() - mock_col.insert_one.return_value = Mock(inserted_id=ObjectId()) + mock_col.insert_one.return_value = Mock(inserted_id=uuid.uuid4().hex) mock_handle_img = Mock(return_value=("", None)) with patch( @@ -3154,7 +3153,7 @@ class TestUpdateAgentFormDataPath: """Cover line 699: form data parsing path.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -3182,7 +3181,7 @@ class TestUpdateAgentFormDataPath: """Cover lines 815-816: invalid source ID in sources list.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -3209,7 +3208,7 @@ class TestUpdateAgentFormDataPath: """Cover line 859: tools is not a list.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -3236,7 +3235,7 @@ class TestUpdateAgentFormDataPath: """Cover line 887: limited_token_mode string 'True' converted.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -3263,7 +3262,7 @@ class TestUpdateAgentFormDataPath: """Cover lines 948-951: request_limit without limited_request_mode.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -3293,15 +3292,15 @@ class TestUpdateAgentFormDataPath: """Cover lines 950-951: folder_id field update.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1) mock_handle_img = Mock(return_value=("", None)) - folder_oid = str(ObjectId()) + folder_oid = str(uuid.uuid4().hex) mock_folders_col = Mock() - mock_folders_col.find_one.return_value = {"_id": ObjectId(folder_oid), "user": "user1"} + mock_folders_col.find_one.return_value = {"_id": folder_oid, "user": "user1"} with patch( "application.api.user.agents.routes.agents_collection", mock_col @@ -3325,7 +3324,7 @@ class TestUpdateAgentFormDataPath: """Cover line 1073: published with source_val check.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = { "_id": agent_id, "user": "user1", @@ -3362,7 +3361,7 @@ class TestUpdateAgentFormDataPath: """Cover line 1108: matched_count==0 returns 404.""" from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -3393,7 +3392,7 @@ class TestGetPinnedAgents: def test_returns_pinned_agents_with_masked_key(self, app): from application.api.user.agents.routes import PinnedAgents - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_col = Mock() mock_col.find.return_value = [ { @@ -3486,7 +3485,7 @@ class TestCreateAgentWorkflowError: from application.api.user.agents.routes import CreateAgent mock_col = Mock() - mock_col.insert_one.return_value = Mock(inserted_id=ObjectId()) + mock_col.insert_one.return_value = Mock(inserted_id=uuid.uuid4().hex) error_response = Mock() error_response.status_code = 400 @@ -3532,7 +3531,7 @@ class TestUpdateAgentInvalidSourceInList: def test_invalid_source_in_list(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -3572,7 +3571,7 @@ class TestUpdateAgentModelsField: def test_models_field_update(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing @@ -3616,7 +3615,7 @@ class TestUpdateAgentHasValidSourceDefault: def test_has_valid_source_with_default(self, app): from application.api.user.agents.routes import UpdateAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex existing = self._make_existing_agent(agent_id) mock_col = Mock() mock_col.find_one.return_value = existing diff --git a/tests/api/user/test_agents_sharing.py b/tests/api/user/test_agents_sharing.py index eb626508..b750868d 100644 --- a/tests/api/user/test_agents_sharing.py +++ b/tests/api/user/test_agents_sharing.py @@ -1,9 +1,9 @@ """Tests for application.api.user.agents.sharing module.""" +import uuid from unittest.mock import Mock, patch import pytest -from bson import DBRef, ObjectId from flask import Flask @@ -44,7 +44,7 @@ class TestSharedAgent: def test_returns_shared_agent_data(self, app): from application.api.user.agents.sharing import SharedAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.find_one.return_value = { "_id": agent_id, @@ -85,7 +85,7 @@ class TestSharedAgent: def test_adds_to_shared_with_me_for_different_user(self, app): from application.api.user.agents.sharing import SharedAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.find_one.return_value = { "_id": agent_id, @@ -123,7 +123,7 @@ class TestSharedAgent: def test_does_not_add_to_shared_for_owner(self, app): from application.api.user.agents.sharing import SharedAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.find_one.return_value = { "_id": agent_id, @@ -161,8 +161,8 @@ class TestSharedAgent: def test_enriches_tool_names(self, app): from application.api.user.agents.sharing import SharedAgent - agent_id = ObjectId() - tool_id = str(ObjectId()) + agent_id = uuid.uuid4().hex + tool_id = str(uuid.uuid4().hex) mock_agents_col = Mock() mock_agents_col.find_one.return_value = { "_id": agent_id, @@ -174,7 +174,7 @@ class TestSharedAgent: } mock_tools_col = Mock() mock_tools_col.find_one.return_value = { - "_id": ObjectId(tool_id), + "_id": tool_id, "name": "calculator", } mock_resolve = Mock(return_value=[]) @@ -200,9 +200,9 @@ class TestSharedAgent: def test_handles_source_dbref(self, app): from application.api.user.agents.sharing import SharedAgent - agent_id = ObjectId() - source_id = ObjectId() - source_ref = DBRef("sources", source_id) + agent_id = uuid.uuid4().hex + source_id = uuid.uuid4().hex + source_ref = uuid.uuid4().hex # TODO: was DBRef("sources", source_id) mock_agents_col = Mock() mock_agents_col.find_one.return_value = { "_id": agent_id, @@ -248,8 +248,8 @@ class TestSharedAgent: def test_tool_enrichment_handles_missing_tool(self, app): from application.api.user.agents.sharing import SharedAgent - agent_id = ObjectId() - tool_id = str(ObjectId()) + agent_id = uuid.uuid4().hex + tool_id = str(uuid.uuid4().hex) mock_agents_col = Mock() mock_agents_col.find_one.return_value = { "_id": agent_id, @@ -285,7 +285,7 @@ class TestSharedAgent: def test_image_url_generated_when_present(self, app): from application.api.user.agents.sharing import SharedAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_agents_col = Mock() mock_agents_col.find_one.return_value = { "_id": agent_id, @@ -340,7 +340,7 @@ class TestSharedAgents: def test_returns_shared_agents_list(self, app): from application.api.user.agents.sharing import SharedAgents - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_ensure = Mock( return_value={ "user_id": "user1", @@ -389,7 +389,7 @@ class TestSharedAgents: def test_removes_stale_shared_ids(self, app): from application.api.user.agents.sharing import SharedAgents - stale_id = str(ObjectId()) + stale_id = str(uuid.uuid4().hex) mock_ensure = Mock( return_value={ "user_id": "user1", @@ -465,7 +465,7 @@ class TestSharedAgents: def test_image_url_generated(self, app): from application.api.user.agents.sharing import SharedAgents - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_ensure = Mock( return_value={ "user_id": "user1", @@ -567,7 +567,7 @@ class TestShareAgent: with app.test_request_context( "/api/share_agent", method="PUT", - json={"id": str(ObjectId())}, + json={"id": str(uuid.uuid4().hex)}, ): from flask import request @@ -594,7 +594,7 @@ class TestShareAgent: mock_col = Mock() mock_col.find_one.return_value = None - agent_id = str(ObjectId()) + agent_id = str(uuid.uuid4().hex) with patch( "application.api.user.agents.sharing.agents_collection", mock_col @@ -613,7 +613,7 @@ class TestShareAgent: def test_shares_agent_success(self, app): from application.api.user.agents.sharing import ShareAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_col = Mock() mock_col.find_one.return_value = { "_id": agent_id, @@ -646,7 +646,7 @@ class TestShareAgent: def test_unshares_agent_success(self, app): from application.api.user.agents.sharing import ShareAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_col = Mock() mock_col.find_one.return_value = { "_id": agent_id, @@ -677,7 +677,7 @@ class TestShareAgent: def test_returns_400_on_db_exception(self, app): from application.api.user.agents.sharing import ShareAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_col = Mock() mock_col.find_one.return_value = { "_id": agent_id, @@ -705,7 +705,7 @@ class TestShareAgent: def test_share_with_username(self, app): from application.api.user.agents.sharing import ShareAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_col = Mock() mock_col.find_one.return_value = { "_id": agent_id, @@ -739,7 +739,7 @@ class TestShareAgent: def test_shared_false_explicitly(self, app): from application.api.user.agents.sharing import ShareAgent - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_col = Mock() mock_col.find_one.return_value = { "_id": agent_id, diff --git a/tests/api/user/test_analytics.py b/tests/api/user/test_analytics.py index 62dd67a6..b45e7454 100644 --- a/tests/api/user/test_analytics.py +++ b/tests/api/user/test_analytics.py @@ -1,8 +1,8 @@ import datetime +import uuid from unittest.mock import Mock, patch import pytest -from bson import ObjectId from flask import Flask @@ -87,7 +87,7 @@ class TestGetMessageAnalytics: def test_filters_by_api_key(self, app): from application.api.user.analytics.routes import GetMessageAnalytics - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_agents = Mock() mock_agents.find_one.return_value = { "_id": agent_id, @@ -295,7 +295,7 @@ class TestGetUserLogs: def test_returns_paginated_logs(self, app): from application.api.user.analytics.routes import GetUserLogs - log_id = ObjectId() + log_id = uuid.uuid4().hex mock_cursor = Mock() mock_cursor.sort.return_value.skip.return_value.limit.return_value = [ { @@ -341,7 +341,7 @@ class TestGetUserLogs: from application.api.user.analytics.routes import GetUserLogs items = [ - {"_id": ObjectId(), "action": f"q{i}", "level": "info"} + {"_id": uuid.uuid4().hex, "action": f"q{i}", "level": "info"} for i in range(3) ] mock_cursor = Mock() @@ -471,7 +471,7 @@ class TestGetTokenAnalyticsAdditional: def test_filters_by_api_key(self, app): from application.api.user.analytics.routes import GetTokenAnalytics - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_agents = Mock() mock_agents.find_one.return_value = { "_id": agent_id, @@ -519,7 +519,7 @@ class TestGetTokenAnalyticsAdditional: method="POST", json={ "filter_option": "last_30_days", - "api_key_id": str(ObjectId()), + "api_key_id": str(uuid.uuid4().hex), }, ): from flask import request @@ -660,7 +660,7 @@ class TestGetFeedbackAnalyticsAdditional: def test_filters_by_api_key(self, app): from application.api.user.analytics.routes import GetFeedbackAnalytics - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_agents = Mock() mock_agents.find_one.return_value = { "_id": agent_id, @@ -708,7 +708,7 @@ class TestGetFeedbackAnalyticsAdditional: method="POST", json={ "filter_option": "last_30_days", - "api_key_id": str(ObjectId()), + "api_key_id": str(uuid.uuid4().hex), }, ): from flask import request @@ -765,7 +765,7 @@ class TestGetMessageAnalyticsAdditional: method="POST", json={ "filter_option": "last_30_days", - "api_key_id": str(ObjectId()), + "api_key_id": str(uuid.uuid4().hex), }, ): from flask import request @@ -837,7 +837,7 @@ class TestGetUserLogsAdditional: def test_filters_by_api_key(self, app): from application.api.user.analytics.routes import GetUserLogs - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_agents = Mock() mock_agents.find_one.return_value = { "_id": agent_id, @@ -888,7 +888,7 @@ class TestGetUserLogsAdditional: method="POST", json={ "page": 1, - "api_key_id": str(ObjectId()), + "api_key_id": str(uuid.uuid4().hex), }, ): from flask import request diff --git a/tests/api/user/test_conversations.py b/tests/api/user/test_conversations.py index 6fc67fa8..35f27dbe 100644 --- a/tests/api/user/test_conversations.py +++ b/tests/api/user/test_conversations.py @@ -301,7 +301,7 @@ class TestSubmitFeedback: "/api/feedback", method="POST", json={ - "feedback": "LIKE", + "feedback": "like", "conversation_id": str(conv_id), "question_index": 0, }, @@ -350,7 +350,7 @@ class TestSubmitFeedback: with app.test_request_context( "/api/feedback", method="POST", - json={"feedback": "LIKE"}, + json={"feedback": "like"}, ): from flask import request diff --git a/tests/api/user/test_folders.py b/tests/api/user/test_folders.py index a5757071..8077bad5 100644 --- a/tests/api/user/test_folders.py +++ b/tests/api/user/test_folders.py @@ -1,8 +1,8 @@ import datetime +import uuid from unittest.mock import Mock, patch import pytest -from bson import ObjectId from flask import Flask @@ -19,7 +19,7 @@ class TestAgentFoldersGet: from application.api.user.agents.folders import AgentFolders now = datetime.datetime(2024, 6, 15, tzinfo=datetime.timezone.utc) - folder_id = ObjectId() + folder_id = uuid.uuid4().hex mock_collection = Mock() mock_collection.find.return_value = [ { @@ -65,7 +65,7 @@ class TestAgentFoldersCreate: def test_creates_folder(self, app): from application.api.user.agents.folders import AgentFolders - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_collection = Mock() mock_collection.insert_one.return_value = Mock(inserted_id=inserted_id) @@ -115,7 +115,7 @@ class TestAgentFoldersCreate: with app.test_request_context( "/api/agents/folders/", method="POST", - json={"name": "Sub", "parent_id": str(ObjectId())}, + json={"name": "Sub", "parent_id": str(uuid.uuid4().hex)}, ): from flask import request @@ -131,9 +131,9 @@ class TestAgentFolderGet: def test_returns_folder_with_agents_and_subfolders(self, app): from application.api.user.agents.folders import AgentFolder - folder_id = ObjectId() - agent_id = ObjectId() - subfolder_id = ObjectId() + folder_id = uuid.uuid4().hex + agent_id = uuid.uuid4().hex + subfolder_id = uuid.uuid4().hex mock_folders = Mock() mock_folders.find_one.return_value = { "_id": folder_id, @@ -179,12 +179,12 @@ class TestAgentFolderGet: mock_collection, ): with app.test_request_context( - f"/api/agents/folders/{ObjectId()}", method="GET" + f"/api/agents/folders/{uuid.uuid4().hex}", method="GET" ): from flask import request request.decoded_token = {"sub": "user1"} - response = AgentFolder().get(str(ObjectId())) + response = AgentFolder().get(str(uuid.uuid4().hex)) assert response.status_code == 404 @@ -195,7 +195,7 @@ class TestAgentFolderUpdate: def test_updates_folder_name(self, app): from application.api.user.agents.folders import AgentFolder - folder_id = ObjectId() + folder_id = uuid.uuid4().hex mock_collection = Mock() mock_collection.update_one.return_value = Mock(matched_count=1) @@ -219,7 +219,7 @@ class TestAgentFolderUpdate: def test_prevents_self_parent(self, app): from application.api.user.agents.folders import AgentFolder - folder_id = str(ObjectId()) + folder_id = str(uuid.uuid4().hex) with app.test_request_context( f"/api/agents/folders/{folder_id}", @@ -245,14 +245,14 @@ class TestAgentFolderUpdate: mock_collection, ): with app.test_request_context( - f"/api/agents/folders/{ObjectId()}", + f"/api/agents/folders/{uuid.uuid4().hex}", method="PUT", json={"name": "X"}, ): from flask import request request.decoded_token = {"sub": "user1"} - response = AgentFolder().put(str(ObjectId())) + response = AgentFolder().put(str(uuid.uuid4().hex)) assert response.status_code == 404 @@ -263,7 +263,7 @@ class TestAgentFolderDelete: def test_deletes_folder_and_unsets_references(self, app): from application.api.user.agents.folders import AgentFolder - folder_id = str(ObjectId()) + folder_id = str(uuid.uuid4().hex) mock_folders = Mock() mock_folders.delete_one.return_value = Mock(deleted_count=1) mock_agents = Mock() @@ -303,12 +303,12 @@ class TestAgentFolderDelete: mock_agents, ): with app.test_request_context( - f"/api/agents/folders/{ObjectId()}", method="DELETE" + f"/api/agents/folders/{uuid.uuid4().hex}", method="DELETE" ): from flask import request request.decoded_token = {"sub": "user1"} - response = AgentFolder().delete(str(ObjectId())) + response = AgentFolder().delete(str(uuid.uuid4().hex)) assert response.status_code == 404 @@ -319,8 +319,8 @@ class TestMoveAgentToFolder: def test_moves_agent_to_folder(self, app): from application.api.user.agents.folders import MoveAgentToFolder - agent_id = ObjectId() - folder_id = ObjectId() + agent_id = uuid.uuid4().hex + folder_id = uuid.uuid4().hex mock_agents = Mock() mock_agents.find_one.return_value = {"_id": agent_id, "user": "user1"} mock_folders = Mock() @@ -352,7 +352,7 @@ class TestMoveAgentToFolder: def test_removes_agent_from_folder(self, app): from application.api.user.agents.folders import MoveAgentToFolder - agent_id = ObjectId() + agent_id = uuid.uuid4().hex mock_agents = Mock() mock_agents.find_one.return_value = {"_id": agent_id, "user": "user1"} @@ -387,7 +387,7 @@ class TestMoveAgentToFolder: with app.test_request_context( "/api/agents/folders/move_agent", method="POST", - json={"agent_id": str(ObjectId())}, + json={"agent_id": str(uuid.uuid4().hex)}, ): from flask import request @@ -418,8 +418,8 @@ class TestBulkMoveAgents: def test_bulk_moves_to_folder(self, app): from application.api.user.agents.folders import BulkMoveAgents - folder_id = ObjectId() - agent_ids = [str(ObjectId()), str(ObjectId())] + folder_id = uuid.uuid4().hex + agent_ids = [str(uuid.uuid4().hex), str(uuid.uuid4().hex)] mock_agents = Mock() mock_folders = Mock() mock_folders.find_one.return_value = {"_id": folder_id} @@ -447,7 +447,7 @@ class TestBulkMoveAgents: def test_bulk_removes_from_folders(self, app): from application.api.user.agents.folders import BulkMoveAgents - agent_ids = [str(ObjectId())] + agent_ids = [str(uuid.uuid4().hex)] mock_agents = Mock() with patch( @@ -497,8 +497,8 @@ class TestBulkMoveAgents: "/api/agents/folders/bulk_move", method="POST", json={ - "agent_ids": [str(ObjectId())], - "folder_id": str(ObjectId()), + "agent_ids": [str(uuid.uuid4().hex)], + "folder_id": str(uuid.uuid4().hex), }, ): from flask import request @@ -581,13 +581,13 @@ class TestAgentFoldersGaps: mock_folders, ): with app.test_request_context( - "/api/agents/folders/" + str(ObjectId()), + "/api/agents/folders/" + str(uuid.uuid4().hex), method="GET", ): from flask import request request.decoded_token = {"sub": "user1"} - response = AgentFolder().get(str(ObjectId())) + response = AgentFolder().get(str(uuid.uuid4().hex)) assert response.status_code == 400 def test_update_folder_no_auth(self, app): diff --git a/tests/api/user/test_prompts.py b/tests/api/user/test_prompts.py index a9f47c87..2dcfc0ac 100644 --- a/tests/api/user/test_prompts.py +++ b/tests/api/user/test_prompts.py @@ -1,7 +1,7 @@ +import uuid from unittest.mock import Mock, mock_open, patch import pytest -from bson import ObjectId from flask import Flask @@ -19,7 +19,7 @@ class TestCreatePrompt: mock_collection = Mock() mock_repo = Mock() - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_collection.insert_one.return_value = Mock(inserted_id=inserted_id) def _run_dual_write(_repo_cls, fn): @@ -92,7 +92,7 @@ class TestGetPrompts: def test_returns_prompts_with_defaults(self, app): from application.api.user.prompts.routes import GetPrompts - user_prompt_id = ObjectId() + user_prompt_id = uuid.uuid4().hex mock_collection = Mock() mock_collection.find.return_value = [ {"_id": user_prompt_id, "name": "Custom Prompt"} @@ -175,7 +175,7 @@ class TestGetSinglePrompt: def test_returns_custom_prompt(self, app): from application.api.user.prompts.routes import GetSinglePrompt - prompt_id = ObjectId() + prompt_id = uuid.uuid4().hex mock_collection = Mock() mock_collection.find_one.return_value = { "_id": prompt_id, @@ -215,7 +215,7 @@ class TestDeletePrompt: def test_deletes_prompt(self, app): from application.api.user.prompts.routes import DeletePrompt - prompt_id = ObjectId() + prompt_id = uuid.uuid4().hex mock_collection = Mock() mock_repo = Mock() @@ -268,7 +268,7 @@ class TestUpdatePrompt: def test_updates_prompt(self, app): from application.api.user.prompts.routes import UpdatePrompt - prompt_id = ObjectId() + prompt_id = uuid.uuid4().hex mock_collection = Mock() mock_repo = Mock() @@ -312,7 +312,7 @@ class TestUpdatePrompt: with app.test_request_context( "/api/update_prompt", method="POST", - json={"id": str(ObjectId()), "name": "Updated"}, + json={"id": str(uuid.uuid4().hex), "name": "Updated"}, ): from flask import request diff --git a/tests/api/user/test_tools_mcp.py b/tests/api/user/test_tools_mcp.py index 2c9b93d7..42121a14 100644 --- a/tests/api/user/test_tools_mcp.py +++ b/tests/api/user/test_tools_mcp.py @@ -1,10 +1,10 @@ """Unit tests for application.api.user.tools.mcp.""" import json +import uuid from unittest.mock import Mock, patch import pytest -from bson import ObjectId from flask import Flask @@ -417,7 +417,7 @@ class TestMCPServerSave: def test_creates_new_mcp_server_no_auth(self, app): from application.api.user.tools.mcp import MCPServerSave - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_mcp_tool = Mock() mock_mcp_tool.discover_tools.return_value = None mock_mcp_tool.get_actions_metadata.return_value = [ @@ -459,7 +459,7 @@ class TestMCPServerSave: def test_creates_with_bearer_auth(self, app): from application.api.user.tools.mcp import MCPServerSave - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_mcp_tool = Mock() mock_mcp_tool.discover_tools.return_value = None mock_mcp_tool.get_actions_metadata.return_value = [] @@ -502,7 +502,7 @@ class TestMCPServerSave: def test_updates_existing_mcp_server(self, app): from application.api.user.tools.mcp import MCPServerSave - tool_id = ObjectId() + tool_id = uuid.uuid4().hex mock_mcp_tool = Mock() mock_mcp_tool.discover_tools.return_value = None mock_mcp_tool.get_actions_metadata.return_value = [ @@ -549,7 +549,7 @@ class TestMCPServerSave: def test_returns_404_update_not_found(self, app): from application.api.user.tools.mcp import MCPServerSave - tool_id = ObjectId() + tool_id = uuid.uuid4().hex mock_mcp_tool = Mock() mock_mcp_tool.discover_tools.return_value = None mock_mcp_tool.get_actions_metadata.return_value = [] @@ -644,7 +644,7 @@ class TestMCPServerSave: def test_oauth_auth_completed_successfully(self, app): from application.api.user.tools.mcp import MCPServerSave - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_manager = Mock() mock_manager.get_oauth_status.return_value = { "status": "completed", @@ -736,7 +736,7 @@ class TestMCPServerSave: def test_strips_sensitive_fields_from_storage(self, app): from application.api.user.tools.mcp import MCPServerSave - inserted_id = ObjectId() + inserted_id = uuid.uuid4().hex mock_mcp_tool = Mock() mock_mcp_tool.discover_tools.return_value = None mock_mcp_tool.get_actions_metadata.return_value = [] @@ -784,7 +784,7 @@ class TestMCPServerSave: def test_merges_existing_encrypted_credentials_on_update(self, app): from application.api.user.tools.mcp import MCPServerSave - tool_id = ObjectId() + tool_id = uuid.uuid4().hex mock_mcp_tool = Mock() mock_mcp_tool.discover_tools.return_value = None mock_mcp_tool.get_actions_metadata.return_value = [] @@ -834,7 +834,7 @@ class TestMCPServerSave: def test_preserves_existing_encrypted_when_no_new_credentials(self, app): from application.api.user.tools.mcp import MCPServerSave - tool_id = ObjectId() + tool_id = uuid.uuid4().hex mock_mcp_tool = Mock() mock_mcp_tool.discover_tools.return_value = None mock_mcp_tool.get_actions_metadata.return_value = [] @@ -1105,7 +1105,7 @@ class TestMCPAuthStatus: def test_returns_configured_for_non_oauth_tools(self, app): from application.api.user.tools.mcp import MCPAuthStatus - tool_id = ObjectId() + tool_id = uuid.uuid4().hex mock_collection = Mock() mock_collection.find.return_value = [ { @@ -1130,7 +1130,7 @@ class TestMCPAuthStatus: def test_returns_connected_for_oauth_with_tokens(self, app): from application.api.user.tools.mcp import MCPAuthStatus - tool_id = ObjectId() + tool_id = uuid.uuid4().hex mock_collection = Mock() mock_collection.find.return_value = [ { @@ -1168,7 +1168,7 @@ class TestMCPAuthStatus: def test_returns_needs_auth_for_oauth_without_tokens(self, app): from application.api.user.tools.mcp import MCPAuthStatus - tool_id = ObjectId() + tool_id = uuid.uuid4().hex mock_collection = Mock() mock_collection.find.return_value = [ { @@ -1201,7 +1201,7 @@ class TestMCPAuthStatus: def test_returns_needs_auth_for_oauth_without_server_url(self, app): from application.api.user.tools.mcp import MCPAuthStatus - tool_id = ObjectId() + tool_id = uuid.uuid4().hex mock_collection = Mock() mock_collection.find.return_value = [ { @@ -1226,7 +1226,7 @@ class TestMCPAuthStatus: def test_returns_configured_for_none_auth_type(self, app): from application.api.user.tools.mcp import MCPAuthStatus - tool_id = ObjectId() + tool_id = uuid.uuid4().hex mock_collection = Mock() mock_collection.find.return_value = [ {"_id": tool_id, "config": {}} @@ -1267,9 +1267,9 @@ class TestMCPAuthStatus: def test_multiple_tools_mixed_auth(self, app): from application.api.user.tools.mcp import MCPAuthStatus - tool_id_1 = ObjectId() - tool_id_2 = ObjectId() - tool_id_3 = ObjectId() + tool_id_1 = uuid.uuid4().hex + tool_id_2 = uuid.uuid4().hex + tool_id_3 = uuid.uuid4().hex mock_collection = Mock() mock_collection.find.return_value = [ {"_id": tool_id_1, "config": {"auth_type": "api_key"}}, diff --git a/tests/api/v1/test_routes.py b/tests/api/v1/test_routes.py deleted file mode 100644 index e9dea17f..00000000 --- a/tests/api/v1/test_routes.py +++ /dev/null @@ -1,64 +0,0 @@ -from flask import Flask - -from application.api.v1.routes import v1_bp - - -class _FakeCollection: - def __init__(self, docs): - self.docs = docs - - def find_one(self, query): - for doc in self.docs: - if all(doc.get(k) == v for k, v in query.items()): - return doc - return None - - def find(self, query): - return [doc for doc in self.docs if all(doc.get(k) == v for k, v in query.items())] - - -def _build_app(): - app = Flask(__name__) - app.register_blueprint(v1_bp) - return app - - -def test_v1_models_does_not_expose_agent_keys(monkeypatch): - docs = [ - {"_id": "agent-1", "key": "key-1", "user": "user-1", "name": "Agent One"}, - {"_id": "agent-2", "key": "key-2", "user": "user-1", "name": "Agent Two"}, - ] - - fake_mongo = {"testdb": {"agents": _FakeCollection(docs)}} - monkeypatch.setattr("application.api.v1.routes.MongoDB.get_client", lambda: fake_mongo) - monkeypatch.setattr("application.api.v1.routes.settings.MONGO_DB_NAME", "testdb") - - app = _build_app() - client = app.test_client() - response = client.get("/v1/models", headers={"Authorization": "Bearer key-1"}) - - assert response.status_code == 200 - payload = response.get_json() - assert payload["object"] == "list" - assert len(payload["data"]) == 2 - assert payload["data"][0]["id"] == "agent-1" - assert payload["data"][1]["id"] == "agent-2" - # Keys must never appear as model IDs - assert all(model["id"] != "key-1" for model in payload["data"]) - assert all(model["id"] != "key-2" for model in payload["data"]) - - -def test_v1_models_invalid_key_returns_401(monkeypatch): - docs = [ - {"_id": "agent-1", "key": "key-1", "user": "user-1", "name": "Agent One"}, - ] - - fake_mongo = {"testdb": {"agents": _FakeCollection(docs)}} - monkeypatch.setattr("application.api.v1.routes.MongoDB.get_client", lambda: fake_mongo) - monkeypatch.setattr("application.api.v1.routes.settings.MONGO_DB_NAME", "testdb") - - app = _build_app() - client = app.test_client() - response = client.get("/v1/models", headers={"Authorization": "Bearer wrong-key"}) - - assert response.status_code == 401 diff --git a/tests/integration/test_sources.py b/tests/integration/test_sources.py index 9363d7f6..3919ef7c 100644 --- a/tests/integration/test_sources.py +++ b/tests/integration/test_sources.py @@ -12,7 +12,6 @@ Endpoints tested: - /api/get_chunks (GET) - Get chunks from source - /api/update_chunk (PUT) - Update chunk - /api/delete_chunk (DELETE) - Delete chunk -- /api/delete_by_ids (GET) - Delete sources by IDs - /api/delete_old (GET) - Delete old sources - /api/directory_structure (GET) - Get directory structure - /api/manage_source_files (POST) - Manage source files @@ -85,8 +84,12 @@ See the API documentation for details. result = response.json() task_id = result.get("task_id") if task_id: - # Wait for processing - time.sleep(5) + # Wait for Celery ingestion (FAISS index build) to finish + # before trying to query chunks. A fixed sleep is too + # flaky on slower machines, so poll task_status instead. + status = self._wait_for_task(task_id, max_wait=60) + if status != "SUCCESS": + return None # Get source ID source_id = self._get_source_id_by_name(test_name) @@ -462,44 +465,6 @@ Created at: {int(time.time())} # Delete Tests # ------------------------------------------------------------------------- - def test_delete_by_ids(self) -> bool: - """Test deleting documents by vector store IDs. - - Note: This endpoint expects vector store document IDs (chunk IDs), - not MongoDB source IDs. Testing with non-existent IDs returns 400. - """ - test_name = "Sources - Delete by IDs" - self.print_header(f"Testing {test_name}") - - if not self.require_auth(test_name): - return True - - try: - # Test endpoint accessibility with a test ID - # Note: This endpoint expects vector document IDs, not source IDs - test_id = "test-document-id-12345" - self.print_info(f"GET /api/delete_by_ids?path={test_id}") - response = self.get("/api/delete_by_ids", params={"path": test_id}) - - self.print_info(f"Status Code: {response.status_code}") - - if response.status_code == 200: - self.print_success("Delete endpoint responded successfully") - self.record_result(test_name, True, "Success") - return True - elif response.status_code == 400: - # 400 is expected when document ID doesn't exist in vector store - self.print_warning("Expected 400 (ID not in vector store)") - self.record_result(test_name, True, "Endpoint works (ID not found)") - return True - else: - self.record_result(test_name, False, f"Status {response.status_code}") - return False - - except Exception as e: - self.record_result(test_name, False, str(e)) - return False - # ------------------------------------------------------------------------- # Directory Structure Tests # ------------------------------------------------------------------------- @@ -656,10 +621,6 @@ Created at: {int(time.time())} # Manage source files self.test_manage_source_files() - time.sleep(1) - - # Delete test (last because it removes data) - self.test_delete_by_ids() return self.print_summary() diff --git a/tests/llm/test_google_ai.py b/tests/llm/test_google_ai.py index f49179d4..8f213c43 100644 --- a/tests/llm/test_google_ai.py +++ b/tests/llm/test_google_ai.py @@ -756,24 +756,12 @@ class TestUploadFileToGoogle: llm._upload_file_to_google({"path": "/nonexistent"}) def test_upload_and_caches_uri(self, llm, monkeypatch): - from unittest.mock import MagicMock - - mock_attachments = MagicMock() - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_attachments) - mock_mongo_client = {"docsgpt": mock_db} - mock_mongodb = MagicMock() - mock_mongodb.get_client.return_value = mock_mongo_client - - monkeypatch.setattr( - "application.core.mongo_db.MongoDB.get_client", - mock_mongodb.get_client, - ) + # The attachment-id cache write goes through AttachmentsRepository + # now; failures there are swallowed with a logged warning, so the + # test just verifies the upload URI is returned end-to-end. monkeypatch.setattr( "application.llm.google_ai.settings", - types.SimpleNamespace( - GOOGLE_API_KEY="k", API_KEY="k", MONGO_DB_NAME="docsgpt" - ), + types.SimpleNamespace(GOOGLE_API_KEY="k", API_KEY="k"), ) result = llm._upload_file_to_google({"path": "/tmp/file.pdf", "_id": "abc"}) # process_file returns fn(path) which calls client.files.upload -> "gs://fake-uri" diff --git a/tests/llm/test_openai.py b/tests/llm/test_openai.py index 50aff6c3..13aa7879 100644 --- a/tests/llm/test_openai.py +++ b/tests/llm/test_openai.py @@ -1153,28 +1153,17 @@ class TestUploadFileToOpenai2: with pytest.raises(FileNotFoundError, match="File not found"): llm._upload_file_to_openai({"path": "/nonexistent.pdf"}) - def test_upload_success_with_id_caching(self, llm, monkeypatch): - """Cover lines 498-514: successful upload with MongoDB caching.""" - from unittest.mock import MagicMock + def test_upload_success_with_id_caching(self, llm): + """Successful upload returns the uploaded file id. + The attachment-id cache write goes through AttachmentsRepository; + failures there are swallowed with a logged warning, so this just + asserts the upload return value flows through. + """ llm.storage = types.SimpleNamespace( file_exists=lambda p: True, process_file=lambda path, fn, **kw: "file-uploaded-id", ) - - mock_collection = MagicMock() - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_collection) - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value=mock_db) - mock_mongo_cls = MagicMock() - mock_mongo_cls.get_client.return_value = mock_client - - monkeypatch.setattr( - "application.core.mongo_db.MongoDB", - mock_mongo_cls, - ) - result = llm._upload_file_to_openai( {"path": "/file.pdf", "_id": "attachment-id"} ) @@ -1400,38 +1389,21 @@ class TestUploadFileToOpenaiLine469: class TestUploadFileToOpenaiLines489To517: """Cover lines 489-517: full upload path.""" - def test_full_upload_with_mongo_caching(self, llm, monkeypatch): - from unittest.mock import MagicMock - + def test_full_upload_with_attachment_caching(self, llm): + # AttachmentsRepository cache-write errors are swallowed; verify + # the uploaded file id returns through. llm.storage = types.SimpleNamespace( file_exists=lambda p: True, process_file=lambda path, fn, **kw: "file-new-id", ) - - mock_collection = MagicMock() - mock_db = MagicMock() - mock_db.__getitem__ = MagicMock(return_value=mock_collection) - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value=mock_db) - mock_mongo_cls = MagicMock() - mock_mongo_cls.get_client.return_value = mock_client - - monkeypatch.setattr("application.core.mongo_db.MongoDB", mock_mongo_cls) - result = llm._upload_file_to_openai({"path": "/doc.pdf", "_id": "att-1"}) assert result == "file-new-id" - def test_upload_without_id_skips_caching(self, llm, monkeypatch): - from unittest.mock import MagicMock - + def test_upload_without_id_skips_caching(self, llm): llm.storage = types.SimpleNamespace( file_exists=lambda p: True, process_file=lambda path, fn, **kw: "file-no-cache", ) - - mock_mongo_cls = MagicMock() - monkeypatch.setattr("application.core.mongo_db.MongoDB", mock_mongo_cls) - result = llm._upload_file_to_openai({"path": "/doc.pdf"}) assert result == "file-no-cache" diff --git a/tests/test_app.py b/tests/test_app.py index 3c5d7937..a423ca42 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -15,9 +15,7 @@ def test_app_config(): app.config["UPLOAD_FOLDER"] = "inputs" app.config["CELERY_BROKER_URL"] = settings.CELERY_BROKER_URL app.config["CELERY_RESULT_BACKEND"] = settings.CELERY_RESULT_BACKEND - app.config["MONGO_URI"] = settings.MONGO_URI assert app.config["UPLOAD_FOLDER"] == "inputs" assert app.config["CELERY_BROKER_URL"] == settings.CELERY_BROKER_URL assert app.config["CELERY_RESULT_BACKEND"] == settings.CELERY_RESULT_BACKEND - assert app.config["MONGO_URI"] == settings.MONGO_URI diff --git a/tests/test_logging.py b/tests/test_logging.py index a9329284..dd4063c4 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,4 +1,4 @@ -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest @@ -154,49 +154,3 @@ class TestLogActivity: list(failing_gen(FakeAgent())) -@pytest.mark.unit -class TestLogToMongoDB: - - def test_logs_entry(self): - from application.logging import _log_to_mongodb - - mock_collection = Mock() - mock_db = {"stack_logs": mock_collection} - - with patch( - "application.logging.MongoDB.get_client", - return_value={"docsgpt": mock_db}, - ), patch("application.logging.settings") as mock_settings: - mock_settings.MONGO_DB_NAME = "docsgpt" - _log_to_mongodb("ep", "aid", "user", "key", "q", [], "info") - - mock_collection.insert_one.assert_called_once() - doc = mock_collection.insert_one.call_args[0][0] - assert doc["endpoint"] == "ep" - assert doc["level"] == "info" - - def test_truncates_long_strings(self): - from application.logging import _log_to_mongodb - - mock_collection = Mock() - mock_db = {"stack_logs": mock_collection} - - with patch( - "application.logging.MongoDB.get_client", - return_value={"docsgpt": mock_db}, - ), patch("application.logging.settings") as mock_settings: - mock_settings.MONGO_DB_NAME = "docsgpt" - _log_to_mongodb("ep", "aid", "user", "key", "x" * 20000, [], "info") - - doc = mock_collection.insert_one.call_args[0][0] - assert len(doc["query"]) == 10000 - - def test_handles_mongo_error(self): - from application.logging import _log_to_mongodb - - with patch( - "application.logging.MongoDB.get_client", - side_effect=Exception("DB down"), - ): - # Should not raise - _log_to_mongodb("ep", "aid", "user", "key", "q", [], "info") diff --git a/tests/test_remaining_coverage.py b/tests/test_remaining_coverage.py index 62d430bb..95ce8b9b 100644 --- a/tests/test_remaining_coverage.py +++ b/tests/test_remaining_coverage.py @@ -51,38 +51,6 @@ class TestCeleryInitConfigLoggers: mock_setup.assert_called() -# --------------------------------------------------------------------------- -# application/core/mongo_db.py (lines 22-24) -# --------------------------------------------------------------------------- -@pytest.mark.unit -class TestMongoDBCloseClient: - def test_close_client_when_connected(self): - """Cover lines 22-24: close_client closes and sets to None.""" - from application.core.mongo_db import MongoDB - - mock_client = MagicMock() - original = MongoDB._client - try: - MongoDB._client = mock_client - MongoDB.close_client() - mock_client.close.assert_called_once() - assert MongoDB._client is None - finally: - MongoDB._client = original - - def test_close_client_when_not_connected(self): - """Cover: close_client is no-op when _client is None.""" - from application.core.mongo_db import MongoDB - - original = MongoDB._client - try: - MongoDB._client = None - MongoDB.close_client() # Should not raise - assert MongoDB._client is None - finally: - MongoDB._client = original - - # --------------------------------------------------------------------------- # application/llm/docsgpt_provider.py (lines 10, 29, 51) # --------------------------------------------------------------------------- diff --git a/tests/test_seeder.py b/tests/test_seeder.py deleted file mode 100644 index e63882d0..00000000 --- a/tests/test_seeder.py +++ /dev/null @@ -1,492 +0,0 @@ -from unittest.mock import MagicMock, patch, mock_open - -import mongomock -import pytest -from bson import ObjectId - -from application.seed.seeder import DatabaseSeeder - - -@pytest.fixture -def mock_db(): - client = mongomock.MongoClient() - return client["test_docsgpt"] - - -@pytest.fixture -def seeder(mock_db): - return DatabaseSeeder(mock_db) - - -# ── __init__ ─────────────────────────────────────────────────────────────────── - - -@pytest.mark.unit -class TestDatabaseSeederInit: - def test_collections_set(self, seeder, mock_db): - assert seeder.db is mock_db - assert seeder.tools_collection == mock_db["user_tools"] - assert seeder.sources_collection == mock_db["sources"] - assert seeder.agents_collection == mock_db["agents"] - assert seeder.prompts_collection == mock_db["prompts"] - assert seeder.system_user_id == "__system__" - - -# ── _is_already_seeded ───────────────────────────────────────────────────────── - - -@pytest.mark.unit -class TestIsAlreadySeeded: - def test_not_seeded(self, seeder): - assert seeder._is_already_seeded() is False - - def test_already_seeded(self, seeder, mock_db): - mock_db["agents"].insert_one({"user": "__system__", "name": "test"}) - assert seeder._is_already_seeded() is True - - -# ── _process_config ──────────────────────────────────────────────────────────── - - -@pytest.mark.unit -class TestProcessConfig: - def test_env_var_substitution(self, seeder, monkeypatch): - monkeypatch.setenv("MY_SECRET", "secret_value") - result = seeder._process_config({"key": "${MY_SECRET}"}) - assert result["key"] == "secret_value" - - def test_missing_env_var_defaults_empty(self, seeder, monkeypatch): - monkeypatch.delenv("NONEXISTENT_VAR", raising=False) - result = seeder._process_config({"key": "${NONEXISTENT_VAR}"}) - assert result["key"] == "" - - def test_non_env_value_unchanged(self, seeder): - result = seeder._process_config({"key": "plain_value", "num": 42}) - assert result == {"key": "plain_value", "num": 42} - - def test_partial_env_syntax_unchanged(self, seeder): - result = seeder._process_config({"key": "${INCOMPLETE"}) - assert result["key"] == "${INCOMPLETE" - - def test_empty_config(self, seeder): - assert seeder._process_config({}) == {} - - -# ── _handle_prompt ───────────────────────────────────────────────────────────── - - -@pytest.mark.unit -class TestHandlePrompt: - def test_no_prompt_returns_none(self, seeder): - assert seeder._handle_prompt({"name": "agent1"}) is None - - def test_empty_content_returns_none(self, seeder): - config = {"name": "agent1", "prompt": {"name": "p", "content": ""}} - assert seeder._handle_prompt(config) is None - - def test_creates_prompt(self, seeder, mock_db): - config = { - "name": "agent1", - "prompt": {"name": "My Prompt", "content": "You are helpful."}, - } - result = seeder._handle_prompt(config) - assert result is not None - doc = mock_db["prompts"].find_one({"name": "My Prompt"}) - assert doc is not None - assert doc["content"] == "You are helpful." - assert doc["user"] == "__system__" - - def test_duplicate_prompt_returns_existing(self, seeder, mock_db): - config = { - "name": "agent1", - "prompt": {"name": "Dup Prompt", "content": "content"}, - } - id1 = seeder._handle_prompt(config) - id2 = seeder._handle_prompt(config) - assert id1 == id2 - assert mock_db["prompts"].count_documents({"name": "Dup Prompt"}) == 1 - - def test_default_prompt_name(self, seeder, mock_db): - config = {"name": "agent1", "prompt": {"content": "hello"}} - seeder._handle_prompt(config) - doc = mock_db["prompts"].find_one({"name": "agent1 Prompt"}) - assert doc is not None - - def test_exception_returns_none(self, seeder): - with patch.object( - seeder.prompts_collection, "find_one", side_effect=RuntimeError("db error") - ): - config = { - "name": "agent1", - "prompt": {"name": "p", "content": "c"}, - } - assert seeder._handle_prompt(config) is None - - -# ── _handle_tools ────────────────────────────────────────────────────────────── - - -@pytest.mark.unit -class TestHandleTools: - def test_no_tools_returns_empty(self, seeder): - assert seeder._handle_tools({"name": "agent1"}) == [] - - @patch("application.seed.seeder.tool_manager") - def test_creates_tool(self, mock_tm, seeder, mock_db): - mock_tool = MagicMock() - mock_tool.get_actions_metadata.return_value = [{"name": "act1"}] - mock_tm.tools = {"my_tool": mock_tool} - - config = { - "name": "agent1", - "tools": [{"name": "my_tool", "description": "desc"}], - } - ids = seeder._handle_tools(config) - assert len(ids) == 1 - doc = mock_db["user_tools"].find_one({"name": "my_tool"}) - assert doc is not None - assert doc["user"] == "__system__" - - @patch("application.seed.seeder.tool_manager") - def test_duplicate_tool_returns_existing(self, mock_tm, seeder, mock_db): - mock_tool = MagicMock() - mock_tool.get_actions_metadata.return_value = [] - mock_tm.tools = {"my_tool": mock_tool} - - config = { - "name": "agent1", - "tools": [{"name": "my_tool"}], - } - ids1 = seeder._handle_tools(config) - ids2 = seeder._handle_tools(config) - assert ids1 == ids2 - assert mock_db["user_tools"].count_documents({"name": "my_tool"}) == 1 - - def test_tool_exception_continues(self, seeder): - config = { - "name": "agent1", - "tools": [{"name": "broken_tool"}], - } - # tool_manager.tools will KeyError on "broken_tool" - ids = seeder._handle_tools(config) - assert ids == [] - - @patch("application.seed.seeder.tool_manager") - def test_tool_config_env_expansion(self, mock_tm, seeder, monkeypatch): - monkeypatch.setenv("TOOL_KEY", "expanded_val") - mock_tool = MagicMock() - mock_tool.get_actions_metadata.return_value = [] - mock_tm.tools = {"my_tool": mock_tool} - - config = { - "name": "agent1", - "tools": [{"name": "my_tool", "config": {"api_key": "${TOOL_KEY}"}}], - } - seeder._handle_tools(config) - doc = seeder.tools_collection.find_one({"name": "my_tool"}) - assert doc["config"]["api_key"] == "expanded_val" - - -# ── _handle_source ───────────────────────────────────────────────────────────── - - -@pytest.mark.unit -class TestHandleSource: - def test_no_source_returns_none(self, seeder): - assert seeder._handle_source({"name": "a"}) is None - - def test_existing_source_returns_id(self, seeder, mock_db): - inserted = mock_db["sources"].insert_one( - {"user": "__system__", "remote_data": "http://example.com"} - ) - config = { - "name": "a", - "source": {"url": "http://example.com", "name": "src"}, - } - result = seeder._handle_source(config) - assert result == inserted.inserted_id - - @patch("application.seed.seeder.ingest_remote") - def test_new_source_ingestion(self, mock_ingest, seeder): - mock_task = MagicMock() - mock_task.get.return_value = {"id": "new_source_id"} - mock_task.successful.return_value = True - mock_ingest.delay.return_value = mock_task - - config = { - "name": "a", - "source": {"url": "http://new.com", "name": "new_src", "loader": "web"}, - } - result = seeder._handle_source(config) - assert result == "new_source_id" - mock_ingest.delay.assert_called_once_with( - source_data="http://new.com", - job_name="new_src", - user="__system__", - loader="web", - ) - - @patch("application.seed.seeder.ingest_remote") - def test_source_ingestion_failure_returns_false(self, mock_ingest, seeder): - mock_task = MagicMock() - mock_task.get.side_effect = RuntimeError("timeout") - mock_ingest.delay.return_value = mock_task - - config = { - "name": "a", - "source": {"url": "http://fail.com", "name": "fail_src"}, - } - result = seeder._handle_source(config) - assert result is False - - @patch("application.seed.seeder.ingest_remote") - def test_source_missing_id_returns_false(self, mock_ingest, seeder): - mock_task = MagicMock() - mock_task.get.return_value = {"no_id_key": True} - mock_task.successful.return_value = True - mock_ingest.delay.return_value = mock_task - - config = { - "name": "a", - "source": {"url": "http://bad.com", "name": "bad_src"}, - } - result = seeder._handle_source(config) - assert result is False - - @patch("application.seed.seeder.ingest_remote") - def test_default_loader(self, mock_ingest, seeder): - mock_task = MagicMock() - mock_task.get.return_value = {"id": "sid"} - mock_task.successful.return_value = True - mock_ingest.delay.return_value = mock_task - - config = { - "name": "a", - "source": {"url": "http://x.com", "name": "s"}, - } - seeder._handle_source(config) - call_kwargs = mock_ingest.delay.call_args[1] - assert call_kwargs["loader"] == "url" - - -# ── seed_initial_data ────────────────────────────────────────────────────────── - - -@pytest.mark.unit -class TestSeedInitialData: - def test_already_seeded_skips(self, seeder, mock_db): - mock_db["agents"].insert_one({"user": "__system__", "name": "existing"}) - with patch.object(seeder, "_seed_from_config") as mock_seed: - seeder.seed_initial_data() - mock_seed.assert_not_called() - - def test_force_reseeds(self, seeder, mock_db): - mock_db["agents"].insert_one({"user": "__system__", "name": "existing"}) - yaml_content = "agents: []" - with patch("builtins.open", mock_open(read_data=yaml_content)): - with patch.object(seeder, "_seed_from_config") as mock_seed: - seeder.seed_initial_data(force=True) - mock_seed.assert_called_once() - - def test_config_file_not_found_raises(self, seeder): - with pytest.raises(Exception): - seeder.seed_initial_data(config_path="/nonexistent/path.yaml") - - def test_custom_config_path(self, seeder): - yaml_content = "agents:\n - name: test_agent" - with patch("builtins.open", mock_open(read_data=yaml_content)): - with patch.object(seeder, "_seed_from_config") as mock_seed: - seeder.seed_initial_data(config_path="/custom/path.yaml") - mock_seed.assert_called_once() - - -# ── _seed_from_config ────────────────────────────────────────────────────────── - - -@pytest.mark.unit -class TestSeedFromConfig: - def test_no_agents_in_config(self, seeder): - seeder._seed_from_config({}) - assert seeder.agents_collection.count_documents({}) == 0 - - def test_empty_agents_list(self, seeder): - seeder._seed_from_config({"agents": []}) - assert seeder.agents_collection.count_documents({}) == 0 - - @patch.object(DatabaseSeeder, "_handle_source", return_value=None) - @patch.object(DatabaseSeeder, "_handle_tools", return_value=[]) - @patch.object(DatabaseSeeder, "_handle_prompt", return_value=None) - def test_creates_agent(self, mock_prompt, mock_tools, mock_source, seeder, mock_db): - config = { - "agents": [ - { - "name": "TestAgent", - "description": "A test agent", - "agent_type": "classic", - } - ] - } - seeder._seed_from_config(config) - agent = mock_db["agents"].find_one({"name": "TestAgent"}) - assert agent is not None - assert agent["user"] == "__system__" - assert agent["agent_type"] == "classic" - assert agent["status"] == "template" - - @patch.object(DatabaseSeeder, "_handle_source", return_value=None) - @patch.object(DatabaseSeeder, "_handle_tools", return_value=[]) - @patch.object(DatabaseSeeder, "_handle_prompt", return_value=None) - def test_updates_existing_agent(self, mock_prompt, mock_tools, mock_source, seeder, mock_db): - mock_db["agents"].insert_one( - {"user": "__system__", "name": "TestAgent", "description": "old"} - ) - config = { - "agents": [ - { - "name": "TestAgent", - "description": "updated", - "agent_type": "classic", - } - ] - } - seeder._seed_from_config(config) - assert mock_db["agents"].count_documents({"name": "TestAgent"}) == 1 - agent = mock_db["agents"].find_one({"name": "TestAgent"}) - assert agent["description"] == "updated" - - @patch.object(DatabaseSeeder, "_handle_source", return_value=False) - def test_source_failure_skips_agent(self, mock_source, seeder, mock_db): - config = { - "agents": [ - { - "name": "SkippedAgent", - "description": "skip", - "agent_type": "classic", - } - ] - } - seeder._seed_from_config(config) - assert mock_db["agents"].count_documents({"name": "SkippedAgent"}) == 0 - - @patch.object(DatabaseSeeder, "_handle_source", side_effect=KeyError("name")) - def test_agent_exception_continues(self, mock_source, seeder, mock_db): - config = { - "agents": [ - {"name": "Bad", "description": "x", "agent_type": "y"}, - {"name": "Good", "description": "x", "agent_type": "y"}, - ] - } - with patch.object(seeder, "_handle_tools", return_value=[]): - with patch.object(seeder, "_handle_prompt", return_value=None): - seeder._seed_from_config(config) - # Both agents should be attempted; first errors, second might too - # Main assertion: no unhandled exception - - @patch.object(DatabaseSeeder, "_handle_source", return_value=None) - @patch.object(DatabaseSeeder, "_handle_tools") - @patch.object(DatabaseSeeder, "_handle_prompt", return_value="prompt_id_123") - def test_agent_with_source_and_tools(self, mock_prompt, mock_tools, mock_source, seeder, mock_db): - tool_id = ObjectId() - mock_tools.return_value = [tool_id] - - source_id = ObjectId() - mock_source.return_value = source_id - - config = { - "agents": [ - { - "name": "FullAgent", - "description": "full", - "agent_type": "classic", - "chunks": "5", - "retriever": "classic", - "image": "img.png", - } - ] - } - seeder._seed_from_config(config) - agent = mock_db["agents"].find_one({"name": "FullAgent"}) - assert agent is not None - assert agent["prompt_id"] == "prompt_id_123" - assert str(tool_id) in agent["tools"] - assert agent["chunks"] == "5" - assert agent["image"] == "img.png" - - -# ── initialize_from_env ──────────────────────────────────────────────────────── - - -@pytest.mark.unit -class TestInitializeFromEnv: - @patch("application.seed.seeder.MongoClient") - def test_creates_seeder_from_env(self, mock_client_cls, monkeypatch): - monkeypatch.setenv("MONGO_URI", "mongodb://test:27017") - monkeypatch.setenv("MONGO_DB_NAME", "testdb") - - mock_db = mongomock.MongoClient()["testdb"] - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value=mock_db) - mock_client_cls.return_value = mock_client - - seeder = DatabaseSeeder.initialize_from_env() - mock_client_cls.assert_called_once_with("mongodb://test:27017") - assert isinstance(seeder, DatabaseSeeder) - - @patch("application.seed.seeder.MongoClient") - def test_default_env_values(self, mock_client_cls, monkeypatch): - monkeypatch.delenv("MONGO_URI", raising=False) - monkeypatch.delenv("MONGO_DB_NAME", raising=False) - - mock_db = mongomock.MongoClient()["docsgpt"] - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value=mock_db) - mock_client_cls.return_value = mock_client - - DatabaseSeeder.initialize_from_env() - mock_client_cls.assert_called_once_with("mongodb://localhost:27017") - - -# ── seed CLI commands ────────────────────────────────────────────────────────── - - -@pytest.mark.unit -class TestSeedCommands: - @patch("application.seed.commands.DatabaseSeeder") - @patch("application.seed.commands.MongoDB") - @patch("application.seed.commands.settings") - def test_init_command(self, mock_settings, mock_mongodb, mock_seeder_cls): - from click.testing import CliRunner - from application.seed.commands import seed - - mock_settings.MONGO_DB_NAME = "testdb" - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value="mock_db") - mock_mongodb.get_client.return_value = mock_client - - mock_seeder = MagicMock() - mock_seeder_cls.return_value = mock_seeder - - runner = CliRunner() - result = runner.invoke(seed, ["init"]) - assert result.exit_code == 0 - mock_seeder.seed_initial_data.assert_called_once_with(force=False) - - @patch("application.seed.commands.DatabaseSeeder") - @patch("application.seed.commands.MongoDB") - @patch("application.seed.commands.settings") - def test_init_command_with_force(self, mock_settings, mock_mongodb, mock_seeder_cls): - from click.testing import CliRunner - from application.seed.commands import seed - - mock_settings.MONGO_DB_NAME = "testdb" - mock_client = MagicMock() - mock_client.__getitem__ = MagicMock(return_value="mock_db") - mock_mongodb.get_client.return_value = mock_client - - mock_seeder = MagicMock() - mock_seeder_cls.return_value = mock_seeder - - runner = CliRunner() - result = runner.invoke(seed, ["init", "--force"]) - assert result.exit_code == 0 - mock_seeder.seed_initial_data.assert_called_once_with(force=True)