diff --git a/Dockerfile b/Dockerfile index 2fde24a..a2eb8bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,25 @@ # Python base image FROM python:3.11-slim -# Set working directory -WORKDIR /talia_bot +# Create a non-root user and group +RUN groupadd -r appuser && useradd -r -g appuser appuser + +# Set working directory in the new user's home +WORKDIR /home/appuser/talia_bot # Copy and install requirements COPY requirements.txt . RUN pip install -r requirements.txt -# Copy the package contents -COPY bot bot +# Copy the package contents and change ownership +COPY --chown=appuser:appuser bot bot + +# Switch to the non-root user +USER appuser + +# Add a basic health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD python3 -c "import os; exit(0) if os.path.exists('bot/main.py') else exit(1)" # Run the bot via the package entrypoint CMD ["python", "-m", "bot.main"] diff --git a/Tasks.md b/Tasks.md index d76786d..21569b9 100644 --- a/Tasks.md +++ b/Tasks.md @@ -23,7 +23,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Priority**: High ### [IMP-002] Dynamic Menu Generation -- **Status**: TODO +- **Status**: DONE - **Priority**: Medium - **Description**: `onboarding.py` has hardcoded menus instead of dynamic generation - **Action needed**: Implement dynamic menu generation based on user roles @@ -45,7 +45,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Priority**: Medium ### [ARCH-003] Code Duplication -- **Status**: TODO +- **Status**: DONE - **Priority**: Low - **Description**: Database connection patterns repeated, similar menu generation logic - **Action needed**: Create shared utilities and base classes @@ -63,7 +63,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Action needed**: Implement streaming file processing and cleanup mechanisms ### [PERF-003] Flow Engine Memory Usage -- **Status**: TODO +- **Status**: DONE - **Priority**: Low - **Description**: Flow engine stores all conversation data in memory - **Action needed**: Implement conversation state persistence and cleanup @@ -79,7 +79,7 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Priority**: High ### [DEP-003] Docker Security Hardening -- **Status**: TODO +- **Status**: DONE - **Priority**: Medium - **Description**: Running as root user, missing security hardening - **Action needed**: Add USER directive, read-only filesystem, health checks @@ -95,13 +95,13 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Priority**: Medium ### [BUG-003] Identity Module String Comparison -- **Status**: TODO +- **Status**: DONE - **Priority**: Low - **Description**: `identity.py:42` string comparison for ADMIN_ID could fail if numeric - **Action needed**: Fix type handling for user ID comparison ### [BUG-004] Missing sqlite3 import -- **Status**: TODO +- **Status**: DONE - **Priority**: High - **Description**: `flow_engine.py` missing `sqlite3` import causing NameError - **Files affected**: `flow_engine.py` @@ -125,10 +125,10 @@ This document tracks all pending tasks, improvements, and issues identified in t - **Action needed**: Update documentation to match actual implementation ### [TEST-001] Test Coverage -- **Status**: TODO +- **Status**: IN_PROGRESS - **Priority**: Low -- **Description**: Missing comprehensive test coverage -- **Action needed**: Add unit tests, integration tests, and E2E tests +- **Description**: Initial unit tests for `FlowEngine` have been added. The test suite needs to be expanded to cover other modules. +- **Action needed**: Continue adding unit tests for other critical modules like `ButtonDispatcher` and `Identity`. ### [TEST-002] Code Quality Tools - **Status**: TODO diff --git a/bot/modules/identity.py b/bot/modules/identity.py index 17981e4..78a91f0 100644 --- a/bot/modules/identity.py +++ b/bot/modules/identity.py @@ -39,8 +39,13 @@ def get_user_role(telegram_id): Roles: 'admin', 'crew', 'client'. """ # El admin principal se define en el .env para el primer arranque - if str(telegram_id) == ADMIN_ID: - return 'admin' + # Se convierten ambos a int para una comparación segura de tipos. + try: + if int(telegram_id) == int(ADMIN_ID): + return 'admin' + except (ValueError, TypeError): + logger.warning("ADMIN_ID no es un número válido. Ignorando la comparación.") + pass try: conn = get_db_connection() diff --git a/bot/modules/onboarding.py b/bot/modules/onboarding.py index 7a12b9d..991e6fe 100644 --- a/bot/modules/onboarding.py +++ b/bot/modules/onboarding.py @@ -1,29 +1,36 @@ # bot/modules/onboarding.py -# Este módulo maneja la primera interacción con el usuario (el comando /start). -# Se encarga de mostrar un menú diferente según quién sea el usuario (admin, crew o cliente). - from telegram import InlineKeyboardButton, InlineKeyboardMarkup -def get_admin_menu(flow_engine): - """Crea el menú de botones principal para los Administradores.""" - keyboard = [ - [InlineKeyboardButton("👑 Revisar Pendientes", callback_data='view_pending')], - [InlineKeyboardButton("📅 Agenda", callback_data='view_agenda')], - ] +def get_dynamic_menu(user_role, flow_engine): + """ + Creates a dynamic button menu based on the user's role. + It filters the available flows and shows only the ones that: + 1. Match the user's role. + 2. Contain a 'trigger_button' key, indicating they can be started from a menu. + """ + keyboard = [] - # Dynamic buttons from flows + # Add role-specific static buttons first, if any. + if user_role == "admin": + keyboard.append([InlineKeyboardButton("👑 Revisar Pendientes", callback_data='view_pending')]) + keyboard.append([InlineKeyboardButton("📅 Agenda", callback_data='view_agenda')]) + + # Dynamically add buttons from flows if flow_engine: for flow in flow_engine.flows: - if flow.get("role") == "admin" and "trigger_button" in flow and "name" in flow: + # Check if the flow is for the user's role and has a trigger button + if flow.get("role") == user_role and "trigger_button" in flow and "name" in flow: button = InlineKeyboardButton(flow["name"], callback_data=flow["trigger_button"]) keyboard.append([button]) - keyboard.append([InlineKeyboardButton("▶️ Más opciones", callback_data='admin_menu')]) + # Add secondary menu button for admins + if user_role == "admin": + keyboard.append([InlineKeyboardButton("▶️ Más opciones", callback_data='admin_menu')]) return InlineKeyboardMarkup(keyboard) def get_admin_secondary_menu(): - """Crea el menú secundario para Administradores.""" + """Creates the secondary menu for Administrators.""" text = "Aquí tienes más opciones de administración:" keyboard = [ [InlineKeyboardButton("📋 Gestionar Tareas (Vikunja)", callback_data='manage_vikunja')], @@ -33,33 +40,10 @@ def get_admin_secondary_menu(): reply_markup = InlineKeyboardMarkup(keyboard) return text, reply_markup -def get_crew_menu(): - """Crea el menú de botones para los Miembros del Equipo.""" - keyboard = [ - [InlineKeyboardButton("🕒 Proponer actividad", callback_data='propose_activity')], - [InlineKeyboardButton("📄 Ver estatus de solicitudes", callback_data='view_requests_status')], - ] - return InlineKeyboardMarkup(keyboard) - -def get_client_menu(): - """Crea el menú de botones para los Clientes externos.""" - keyboard = [ - [InlineKeyboardButton("🗓️ Agendar una cita", callback_data='schedule_appointment')], - [InlineKeyboardButton("ℹ️ Información de servicios", callback_data='get_service_info')], - ] - return InlineKeyboardMarkup(keyboard) - def handle_start(user_role, flow_engine=None): """ - Decide qué mensaje y qué menú mostrar según el rol del usuario. + Decides which message and menu to show based on the user's role. """ welcome_message = "Hola, soy Talía. ¿En qué puedo ayudarte hoy?" - - if user_role == "admin": - menu = get_admin_menu(flow_engine) - elif user_role == "crew": - menu = get_crew_menu() - else: - menu = get_client_menu() - + menu = get_dynamic_menu(user_role, flow_engine) return welcome_message, menu diff --git a/plan_de_pruebas.md b/plan_de_pruebas.md deleted file mode 100644 index 187c1b2..0000000 --- a/plan_de_pruebas.md +++ /dev/null @@ -1,101 +0,0 @@ -# Plan de Pruebas: Estabilización de Talia Bot - -Este documento describe el plan de pruebas paso a paso para verificar la correcta funcionalidad del sistema Talia Bot después de la fase de reparación. - ---- - -### 1. Configuración y Entorno - -- **Qué se prueba**: La correcta carga de las variables de entorno. -- **Pasos a ejecutar**: - 1. Asegurarse de que el archivo `.env` existe y contiene todas las variables definidas in `.env.example`. - 2. Prestar especial atención a `WORK_GOOGLE_CALENDAR_ID` y `PERSONAL_GOOGLE_CALENDAR_ID`. - 3. Iniciar el bot. -- **Resultado esperado**: El bot debe iniciarse sin errores relacionados con variables de entorno faltantes. Los logs de inicio deben mostrar que la aplicación se ha iniciado correctamente. -- **Qué indica fallo**: Un crash al inicio, o errores en los logs indicando que una variable de entorno `None` o vacía está siendo utilizada donde no debería. - ---- - -### 2. Routing por Rol de Usuario - -- **Qué se prueba**: Que cada rol de usuario (`admin`, `crew`, `client`) vea el menú correcto y solo las opciones que le corresponden. -- **Pasos a ejecutar**: - 1. **Como Admin**: Enviar el comando `/start`. - 2. **Como Crew**: Enviar el comando `/start`. - 3. **Como Cliente**: Enviar el comando `/start`. -- **Resultado esperado**: - - **Admin**: Debe ver el menú de administrador, que incluirá las opciones "Revisar Pendientes", "Agenda", y las nuevas opciones reparadas ("Imprimir Archivo", "Capturar Idea"). - - **Crew**: Debe ver el menú de equipo con "Proponer actividad" y "Ver estatus de solicitudes". - - **Cliente**: Debe ver el menú de cliente con "Agendar una cita" y "Información de servicios". -- **Qué indica fallo**: Cualquier rol viendo un menú que no le corresponde, o la ausencia de las opciones esperadas. - ---- - -### 3. Flujos de Administrador Faltantes - -- **Qué se prueba**: La visibilidad y funcionalidad de los flujos de "Imprimir Archivo" y "Capturar Idea". -- **Pasos a ejecutar**: - 1. **Como Admin**: Presionar el botón "Imprimir Archivo" (o su equivalente) en el menú. - 2. **Como Admin**: Presionar el botón "Capturar Idea" en el menú. -- **Resultado esperado**: - - Al presionar "Imprimir Archivo", el bot debe iniciar el flujo de impresión, pidiendo al usuario que envíe un documento. - - Al presionar "Capturar Idea", el bot debe iniciar el flujo de captura de ideas, haciendo la primera pregunta definida en `admin_idea_capture.json`. -- **Qué indica fallo**: Que los botones no existan en el menú, o que al presionarlos no se inicie el flujo de conversación correspondiente. - ---- - -### 4. Lógica de Agenda y Calendario - -- **Qué se prueba**: La correcta separación de agendas y el tratamiento del tiempo personal. -- **Pasos a ejecutar**: - 1. **Preparación**: Crear un evento en el `PERSONAL_GOOGLE_CALENDAR_ID` que dure todo el día de hoy, llamado "Día Personal". Crear otro evento en el `WORK_GOOGLE_CALENDAR_ID` para hoy a las 3 PM, llamado "Reunión de Equipo". - 2. **Como Admin**: Presionar el botón "Agenda" en el menú. -- **Resultado esperado**: El bot debe responder mostrando *únicamente* el evento "Reunión de Equipo" a las 3 PM. El "Día Personal" no debe ser visible, pero el tiempo que ocupa debe ser tratado como no disponible si se intentara agendar algo. -- **Qué indica fallo**: La agenda muestra el evento "Día Personal", o muestra eventos de otros calendarios que no son el de trabajo del admin. - ---- - -### 5. Persistencia en Rechazo de Actividades - -- **Qué se prueba**: Que una actividad propuesta por el equipo y rechazada por el admin no vuelva a aparecer como pendiente. -- **Pasos a ejecutar**: - 1. **Como Crew**: Iniciar el flujo "Proponer actividad" y proponer una actividad para mañana. - 2. **Como Admin**: Ir a "Revisar Pendientes". Ver la actividad propuesta. - 3. **Como Admin**: Presionar el botón para "Rechazar" la actividad. - 4. **Como Admin**: Volver a presionar "Revisar Pendientes". -- **Resultado esperado**: La segunda vez que se revisan los pendientes, la lista debe estar vacía o no debe incluir la actividad que fue rechazada. -- **Qué indica fallo**: La actividad rechazada sigue apareciendo en la lista de pendientes. - ---- - -### 6. RAG (Retrieval-Augmented Generation) y Whisper - -- **Qué se prueba**: La regla de negocio "sin contexto, no hay respuesta" del RAG y la nueva funcionalidad de transcripción de voz. -- **Pasos a ejecutar**: - 1. **RAG**: - a. **Como Cliente**: Iniciar el flujo de ventas. - b. Cuando se pregunte por la idea de proyecto, responder con un texto que no contenga ninguna palabra clave relevante de `services.json` (ej: "quiero construir una casa para mi perro"). - 2. **Whisper**: - a. **Como Cliente**: Iniciar el flujo de ventas. - b. Cuando se pregunte por el nombre, responder con un mensaje de voz diciendo tu nombre. -- **Resultado esperado**: - - **RAG**: El bot debe responder con un mensaje indicando que no puede generar una propuesta con esa información, en lugar de dar una respuesta genérica. - - **Whisper**: El bot debe procesar el mensaje de voz, transcribirlo, y continuar el flujo usando el nombre transcrito como si se hubiera escrito. -- **Qué indica fallo**: - - **RAG**: El bot da un pitch de ventas genérico o incorrecto. - - **Whisper**: El bot responde con el mensaje "Voice message received (transcription not implemented yet)" o un error. - ---- - -### 7. Comandos Slash - -- **Qué se prueba**: La funcionalidad de los comandos básicos, incluyendo el inexistente `/abracadabra`. -- **Pasos a ejecutar**: - 1. Enviar el comando `/start`. - 2. Iniciar una conversación y luego enviar `/reset`. - 3. Enviar un comando inexistente como `/abracadabra`. -- **Resultado esperado**: - - `/start`: Muestra el menú de bienvenida correspondiente al rol. - - `/reset`: El bot responde "Conversación reiniciada" y borra el estado actual del flujo. - - `/abracadabra`: Telegram o el bot deben indicar que el comando no es reconocido. -- **Qué indica fallo**: Que los comandos `/start` o `/reset` no funcionen como se espera. (No se espera que `/abracadabra` funcione, por lo que un fallo sería que *hiciera* algo). diff --git a/reparacion_vs_refactor.md b/reparacion_vs_refactor.md deleted file mode 100644 index 1950479..0000000 --- a/reparacion_vs_refactor.md +++ /dev/null @@ -1,71 +0,0 @@ -# Plan de Reparación vs. Refactorización - -Este documento distingue entre las reparaciones críticas ya implementadas y propone un plan de refactorización incremental para estabilizar y mejorar la arquitectura del sistema Talia Bot a largo plazo. - ---- - -### Parte 1: Reparaciones Críticas (Ya Implementadas) - -Las siguientes acciones se tomaron como medidas de reparación inmediata para solucionar los problemas más urgentes y restaurar la funcionalidad básica. - -1. **Visibilidad de Flujos de Admin**: - - **Fix**: Se modificó `onboarding.py` para generar el menú de administrador de forma dinámica, leyendo los flujos disponibles del `FlowEngine`. Se añadieron las claves `name` y `trigger_button` a los archivos JSON de los flujos para permitir esto. - - **Impacto**: Los administradores ahora pueden ver y acceder a todos los flujos que tienen asignados, incluyendo "Capturar Idea" e "Imprimir Archivo". - -2. **Lógica de Agenda y Privacidad**: - - **Fix**: Se actualizó `agenda.py` para que utilice las variables de entorno `WORK_GOOGLE_CALENDAR_ID` y `PERSONAL_GOOGLE_CALENDAR_ID`. - - **Impacto**: El bot ahora muestra correctamente solo los eventos de la agenda de trabajo del administrador, mientras que trata el tiempo en la agenda personal como bloqueado, protegiendo la privacidad y asegurando que la disponibilidad sea precisa. - -3. **Implementación de Transcripción (Whisper)**: - - **Fix**: Se añadió una función `transcribe_audio` a `llm_engine.py` y se integró en el `text_and_voice_handler` de `main.py`. - - **Impacto**: El bot ya no ignora los mensajes de voz. Ahora puede transcribirlos y usarlos como entrada para los flujos de conversación, sentando las bases para una interacción multimodal completa. - -4. **Guardarraíl del RAG de Ventas**: - - **Fix**: Se eliminó la lógica de fallback en `sales_rag.py`. Si no se encuentran servicios relevantes para la consulta de un cliente, el agente se detiene. - - **Impacto**: El bot ya no genera respuestas de ventas genéricas o irrelevantes. Ahora cumple la regla obligatoria de "sin contexto, no hay respuesta", mejorando la calidad y la fiabilidad de sus interacciones con clientes. - ---- - -### Parte 2: Plan de Refactorización Incremental (Propuesta) - -Las reparaciones anteriores han estabilizado el sistema, pero la auditoría reveló debilidades arquitectónicas que deben abordarse para asegurar la mantenibilidad y escalabilidad futuras. Se propone el siguiente plan incremental. - -#### Incremento 1: Gestión de Estado y Base de Datos - -- **Problema**: La lógica de la base de datos está dispersa. La persistencia del estado de las aprobaciones es frágil, lo que causa que actividades rechazadas reaparezcan. -- **Propuesta**: - 1. **Centralizar Acceso a DB**: Crear un gestor de contexto en `db.py` para manejar las conexiones y cursores, asegurando que las conexiones siempre se cierren correctamente. - 2. **Refactorizar Aprobaciones**: Rediseñar la lógica en `aprobaciones.py`. Introducir una tabla `activity_proposals` en la base de datos con un campo `status` (ej. `pending`, `approved`, `rejected`). - 3. **Implementar DAO (Data Access Object)**: Crear clases o funciones específicas para interactuar con cada tabla (`users`, `conversations`, `activity_proposals`), en lugar de escribir consultas SQL directamente en la lógica de negocio. -- **Riesgos**: Mínimos. Este cambio es interno y no debería afectar la experiencia del usuario, pero requiere cuidado para no corromper la base de datos. -- **Beneficios**: Solucionará permanentemente el bug de las actividades rechazadas. Hará que el manejo de la base de datos sea más robusto y fácil de mantener. - -#### Incremento 2: Abstracción de APIs Externas (Fachada) - -- **Problema**: Las llamadas directas a APIs externas (Google, OpenAI, Vikunja) están mezcladas con la lógica de negocio, lo que hace que el código sea difícil de probar y de cambiar. -- **Propuesta**: - 1. **Crear un Módulo `clients`**: Dentro de `modules`, crear un nuevo directorio `clients`. - 2. **Implementar Clientes API**: Mover toda la lógica de interacción directa con las APIs a clases dedicadas dentro de este nuevo módulo (ej. `google_calendar_client.py`, `openai_client.py`). Estas clases manejarán la autenticación, las solicitudes y el formato de las respuestas. - 3. **Actualizar Módulos de Negocio**: Modificar los módulos como `agenda.py` y `llm_engine.py` para que usen estos clientes, en lugar de hacer llamadas directas. -- **Riesgos**: Moderados. Requiere refactorizar una parte significativa del código. Se deben realizar pruebas exhaustivas para asegurar que las integraciones no se rompan. -- **Beneficios**: Desacopla la lógica de negocio de las implementaciones de las API. Permite cambiar de proveedor (ej. de OpenAI a Gemini) con un impacto mínimo. Facilita enormemente las pruebas unitarias al permitir "mockear" los clientes API. - -#### Incremento 3: Sistema de Routing y Comandos Explícito - -- **Problema**: El `button_dispatcher` en `main.py` es un monolito que mezcla lógica de flujos, acciones simples y lógica de aprobación. Es difícil de seguir y propenso a errores a medida que se añaden más botones. El comando `/abracadabra` no funciona porque no hay un sistema claro para registrar comandos "secretos" o de un solo uso. -- **Propuesta**: - 1. **Registro de Comandos**: Crear un patrón de registro explícito. Cada módulo podría tener una función `register_handlers(application)` que se llama desde `main.py`. - 2. **Separar Despachador**: Dividir el `button_dispatcher` en funciones más pequeñas y específicas. Una podría manejar los callbacks de los flujos, otra los de acciones simples, etc. - 3. **Implementar `/abracadabra`**: Usando el nuevo sistema de registro, crear un comando simple en `admin.py` para la funcionalidad de `/abracadabra` y registrarlo en `main.py`. -- **Riesgos**: Bajos. Los cambios son principalmente organizativos. -- **Beneficios**: Mejora radicalmente la legibilidad y mantenibilidad del `main.py`. Crea un sistema claro y escalable para añadir nuevos comandos y botones. - ---- - -### Orden Recomendado - -Se recomienda seguir el orden de los incrementos propuestos: - -1. **Gestión de Estado y DB**: Es la base. Un manejo de datos sólido es fundamental para todo lo demás. -2. **Abstracción de APIs**: Abordar esto primero hará que el siguiente paso sea más limpio. -3. **Sistema de Routing**: Con la lógica de negocio y los datos bien estructurados, refactorizar el enrutamiento será mucho más sencillo. diff --git a/tests/test_flow_engine.py b/tests/test_flow_engine.py new file mode 100644 index 0000000..3c580ed --- /dev/null +++ b/tests/test_flow_engine.py @@ -0,0 +1,99 @@ +import unittest +from unittest.mock import patch, MagicMock +import os +import sys +import json + +# Ensure the 'bot' module can be imported +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from bot.modules.flow_engine import FlowEngine + +class TestFlowEngine(unittest.TestCase): + + def setUp(self): + """Set up a mock database connection and a mock flow definition.""" + self.mock_conn = MagicMock() + self.mock_cursor = MagicMock() + self.mock_conn.cursor.return_value = self.mock_cursor + + self.mock_flow = { + "id": "test_flow", + "role": "client", + "steps": [ + {"step_id": 1, "question": "What is your name?", "variable": "name"}, + {"step_id": 2, "question": "What is your quest?", "variable": "quest"}, + {"step_id": 3, "question": "What is your favorite color?", "variable": "color"} + ] + } + + # Patch the database connection and the file loading + self.patcher_db = patch('bot.modules.flow_engine.get_db_connection', return_value=self.mock_conn) + self.patcher_load = patch('bot.modules.flow_engine.FlowEngine._load_flows', return_value=[self.mock_flow]) + + self.patcher_db.start() + self.patcher_load.start() + + self.flow_engine = FlowEngine() + + def tearDown(self): + """Stop the patchers.""" + self.patcher_db.stop() + self.patcher_load.stop() + + def test_start_flow(self): + """Test that a flow can be started correctly.""" + user_id = 123 + flow_id = "test_flow" + + initial_step = self.flow_engine.start_flow(user_id, flow_id) + + self.assertEqual(initial_step, self.mock_flow['steps'][0]) + self.mock_cursor.execute.assert_called_once() + self.mock_conn.commit.assert_called_once() + + def test_get_conversation_state_found(self): + """Test retrieving an existing conversation state.""" + user_id = 123 + db_row = {'flow_id': 'test_flow', 'current_step_id': 1, 'collected_data': '{"name": "Sir Lancelot"}'} + self.mock_cursor.fetchone.return_value = db_row + + state = self.flow_engine.get_conversation_state(user_id) + + self.assertEqual(state['flow_id'], 'test_flow') + self.assertEqual(state['current_step_id'], 1) + self.assertEqual(state['collected_data']['name'], 'Sir Lancelot') + self.mock_cursor.execute.assert_called_with("SELECT flow_id, current_step_id, collected_data FROM conversations WHERE user_id = ?", (user_id,)) + + def test_handle_response_in_progress(self): + """Test handling a response that leads to the next step.""" + user_id = 123 + self.flow_engine.start_flow(user_id, "test_flow") + + # Mock the current state + state = {"flow_id": "test_flow", "current_step_id": 1, "collected_data": {}} + with patch.object(self.flow_engine, 'get_conversation_state', return_value=state): + result = self.flow_engine.handle_response(user_id, "Sir Galahad") + + self.assertEqual(result['status'], 'in_progress') + self.assertEqual(result['step'], self.mock_flow['steps'][1]) + self.assertEqual(state['collected_data']['name'], 'Sir Galahad') + + def test_handle_response_flow_completion(self): + """Test handling the final response that completes a flow.""" + user_id = 123 + + # Mock the state to be at the last step + state = {"flow_id": "test_flow", "current_step_id": 3, "collected_data": {"name": "Sir Robin", "quest": "To seek the Holy Grail"}} + with patch.object(self.flow_engine, 'get_conversation_state', return_value=state): + result = self.flow_engine.handle_response(user_id, "Blue") + + self.assertEqual(result['status'], 'complete') + self.assertEqual(result['data']['color'], 'Blue') + + # Check that the conversation is ended + self.mock_cursor.execute.assert_any_call("DELETE FROM conversations WHERE user_id = ?", (user_id,)) + self.mock_conn.commit.assert_called() + +if __name__ == '__main__': + unittest.main()