From 1e7829d433f6a83a9da3831a2b2068c057b24102 Mon Sep 17 00:00:00 2001 From: PAVEL PALMA Date: Tue, 16 Dec 2025 19:07:49 -0600 Subject: [PATCH] UPDATE fix quick replies --- Dockerfile 2 | 15 ++ README 2.md | 236 +++++++++++++++++ pom 2.xml | 241 ++++++++++++++++++ .../ConversationManagerService.java | 26 +- .../QuickRepliesManagerService.java | 42 ++- .../QuickRepliesManagerServiceTest.java | 177 +++++++++++++ 6 files changed, 720 insertions(+), 17 deletions(-) create mode 100644 Dockerfile 2 create mode 100644 README 2.md create mode 100644 pom 2.xml create mode 100644 src/test/java/com/example/service/unit_testing/QuickRepliesManagerServiceTest.java diff --git a/Dockerfile 2 b/Dockerfile 2 new file mode 100644 index 0000000..3711dc0 --- /dev/null +++ b/Dockerfile 2 @@ -0,0 +1,15 @@ +# Java 21.0.6 +# 'jammy' refers to Ubuntu 22.04 LTS, which is a stable and widely used base. + +# FROM maven:3.9.6-eclipse-temurin-21 AS builder +# FROM quay.ocp.banorte.com/base/openjdk-21:maven_3.8 AS builder +# WORKDIR /app +# COPY pom.xml . +# COPY src ./src +# RUN mvn -B clean install -DskipTests -Dmaven.javadoc.skip=true +# FROM eclipse-temurin:21.0.3_9-jre-jammy +FROM quay.ocp.banorte.com/golden/openjdk-21:latest +# COPY --from=builder /app/target/app-jovenes-service-orchestrator-0.0.1-SNAPSHOT.jar app.jar +COPY target/app-jovenes-service-orchestrator-0.0.1-SNAPSHOT.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/README 2.md b/README 2.md new file mode 100644 index 0000000..c38b0a3 --- /dev/null +++ b/README 2.md @@ -0,0 +1,236 @@ +*Key Versions & Management:* + +* *Java Version:* `21` +* *Spring Boot Version:* `3.2.5` (defined in the parent POM) +* *Spring Cloud GCP Version:* `5.3.0` (managed via `spring-cloud-gcp-dependencies`) +* *Spring Cloud Version:* `2023.0.0` (managed via `spring-cloud-dependencies`) + + +This project is a **Spring Boot Service Orchestrator** running on **Java 21**. + +Here is step-by-step guide to getting this deployed locally in your IDE. + +----- + +### Step 1: Ensure Prerequisites + +Before we touch the code, we need to make sure your local machine matches the project requirements found in the `pom.xml` and `Dockerfile`. + +1. **Install Java 21 JDK:** The project explicitly requires Java 21. + * *Check:* Run `java -version` in your terminal. If it doesn't say "21", you need to install it. +2. **Install Maven:** This is used to build the project dependencies. +3. **Install the "Extension Pack for Java" in VS Code:** This includes tools for Maven, debugging, and IntelliSense. +4. **Install Docker (Desktop or Engine):** We will need this to run a local Redis instance. + +----- + +### Step 2: The "Redis Gotcha" (Local Infrastructure) + +If you look at `src/main/resources/application-dev.properties`, you will see this line: +`spring.data.redis.host=localhost`. + + +1. **Start Redis in Docker:** + Open your terminal and run: + ```bash + docker run --name local-redis -p 6379:6379 -d redis + ``` +2. **Verify it's running:** + Run `docker ps`. You should see redis running on port `6379`. + +----- + +### Step 3: Google Cloud Authentication + +This application connects to **Firestore**, **Dialogflow CX**, and **Vertex AI (Gemini)**. It uses the "Application Default Credentials" strategy. + +1. **Install the Google Cloud CLI (`gcloud`)** if you haven't already. +2. **Login:** + In your terminal, run: + ```bash + gcloud auth application-default login + ``` + *This will open a browser window. Log in with your Google account that has access to the `app-jovenes` project.* + +----- + +### Step 4: Configure Local Properties + +We need to tell the application to look at your *local* Redis instead of the cloud one. + +1. Open `src/main/resources/application.properties`. + +2. Ensure the active profile is set to `dev`: + + ```properties + spring.profiles.active=dev + ``` + +----- + +### Step 5: Build the Project + +Now let's download all the dependencies defined in the `pom.xml`. + +1. Open the Command Palette (Ctrl+Shift+P or Cmd+Shift+P). +2. Type **"Maven: Execute Commands"** -\> select the project -\> **"install"**. + * *Alternative:* Open the built-in terminal and run: + ```bash + mvn clean install -DskipTests + ``` + * *Why skip tests?* The tests might try to connect to real cloud services or check specific configs that might fail on the first local run. Let's just get it compiling first. + +----- + +### Step 6: Run the Application + +1. Navigate to `src/main/java/com/example/Orchestrator.java`. +2. You should see a small "Run | Debug" button appear just above the `public static void main` line. +3. Click **Run**. + +**What to watch for in the Console:** + + * You want to see the Spring Boot logo. + * Look for `Started Orchestrator in X seconds`. + * Look for `Netty started on port 8080` (since this is a WebFlux app). + +----- + +### Step 7: Verify it's working + +Since this is an API, let's test the health or a simple endpoint. + +1. The app runs on port **8080** (defined in Dockerfile). +2. The API has Swagger documentation configured. +3. Open your browser and go to: + `http://localhost:8080/webjars/swagger-ui/index.html` . + * *Note:* If Swagger isn't loading, check the console logs for the exact context path. + +### Summary Checklist for you: + + * [ ] Java 21 Installed? + * [ ] Docker running Redis on localhost:6379? + * [ ] `gcloud auth application-default login` run? + * [ ] `application-dev.properties` updated to use `localhost` for Redis? + +### Examples of endpoint call + +### 1\. The Standard Conversation (Dialogflow) + +This is the most common flow. It simulates a user sending a message like "Hola" to the bot. The orchestrator will route this to Dialogflow CX. + +**Request:** + +```bash +curl -X POST http://localhost:8080/api/v1/dialogflow/detect-intent \ +-H "Content-Type: application/json" \ +-d '{ + "mensaje": "Hola, ¿quien eres?", + "usuario": { + "telefono": "5550001234", + "nickname": "DiegoLocal" + }, + "canal": "whatsapp", + "tipo": "INICIO" +}' +``` + +**What to expect:** + + * **Status:** `200 OK` + * **Response:** A JSON object containing `responseText` (the answer from Dialogflow) and `responseId`. + * **Logs:** Check your VS Code terminal. You should see logs like `Initiating detectIntent for session...`. + +----- + +### 2\. The "Smart" Notification Flow (Gemini Router) + +This is the cool part. We will first "push" a notification to the user, and then simulate the user asking a question about it. + +**Step A: Push the Notification** +This tells the system: *"Hey, user 5550001234 just received this alert."* + +```bash +curl -X POST http://localhost:8080/api/v1/dialogflow/notification \ +-H "Content-Type: application/json" \ +-d '{ + "texto": "Tu tarjeta *1234 ha sido bloqueada por seguridad.", + "telefono": "5550001234", + "parametrosOcultos": { + "motivo": "intento_fraude_detectado", + "ubicacion": "CDMX", + "fecha": "Hoy" + } +}' +``` + + * **Check Logs:** You should see `Notification for phone 5550001234 cached`. + +**Step B: User asks a follow-up (The Test)** +Now, ask a question that requires context from that notification. + +```bash +curl -X POST http://localhost:8080/api/v1/dialogflow/detect-intent \ +-H "Content-Type: application/json" \ +-d '{ + "mensaje": "¿Por qué fue bloqueada?", + "usuario": { + "telefono": "5550001234" + }, + "canal": "whatsapp", + "tipo": "CONVERSACION" +}' +``` + + * **What happens internally:** The `MessageEntryFilter` (Gemini) will see the previous notification in the history and classify this as a `NOTIFICATION` follow-up, routing it to the LLM instead of standard Dialogflow. + +----- + +### 3\. Quick Replies (Static Content) + +This tests the `QuickRepliesManagerService`. It fetches a JSON screen definition from your local files (e.g., `home.json`). + +**Request:** + +```bash +curl -X POST http://localhost:8080/api/v1/quick-replies/screen \ +-H "Content-Type: application/json" \ +-d '{ + "usuario": { + "telefono": "5550001234" + }, + "canal": "app", + "tipo": "INICIO", + "pantallaContexto": "pagos" +}' +``` + +**What to expect:** + + * **Response:** A JSON object with a `quick_replies` field containing the title "Home" (loaded from `home.json`). + +----- + +### 4\. Reset Everything (Purge) + +If you want to start fresh (clear the cache and history for "Local"), run this: + +```bash +curl -X DELETE http://localhost:8080/api/v1/data-purge/all +``` + + * **Logs:** You'll see `Starting Redis data purge` and `Starting Firestore data purge`. + +### 5\. Optional testing the llm response with uuid + +```bash +/api/v1/llm/tune-response +{ + "sessionInfo": { + "parameters": { + "uuid": "21270589-184e-4a1a-922d-fb48464211e8" + } + } +} +``` + diff --git a/pom 2.xml b/pom 2.xml new file mode 100644 index 0000000..aecdb7a --- /dev/null +++ b/pom 2.xml @@ -0,0 +1,241 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.11 + + + + com.example + app-jovenes-service-orchestrator + 0.0.1-SNAPSHOT + app-jovenes-service-orchestrator + This serivce handle conversations over Dialogflow and multiple Storage GCP services + + + 21 + 5.4.0 + 2023.0.0 + 6.4.0.RELEASE + 6.1.21 + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + com.google.cloud + spring-cloud-gcp-dependencies + ${spring-cloud-gcp.version} + pom + import + + + com.google.cloud + libraries-bom + 26.40.0 + pom + import + + + io.projectreactor + reactor-bom + 2024.0.8 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework + spring-core + + + + + org.springframework + spring-web + + + org.springdoc + springdoc-openapi-starter-webflux-ui + 2.5.0 + + + org.springframework + spring-core + + + + + com.google.cloud + spring-cloud-gcp-starter-data-firestore + + + org.springframework + spring-core + + + + + com.google.cloud + spring-cloud-gcp-data-firestore + + + org.springframework + spring-core + + + + + com.google.cloud + spring-cloud-gcp-starter-storage + + + org.springframework + spring-core + + + + + org.springframework.boot + spring-boot-starter-data-redis-reactive + + + org.springframework + spring-core + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework + spring-core + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework + spring-core + + + + + com.google.cloud + google-cloud-dialogflow-cx + + + com.google.genai + google-genai + 1.14.0 + + + com.google.protobuf + protobuf-java-util + + + io.projectreactor + reactor-test + test + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.fasterxml.jackson.module + jackson-module-parameter-names + 2.19.0 + + + com.google.api + gax + + + com.google.cloud + google-cloud-dlp + + + io.netty + netty-codec-http2 + 4.1.125.Final + + + io.netty + netty-handler + 4.1.125.Final + + + io.netty + netty-common + 4.1.125.Final + + + io.netty + netty-codec-http + 4.1.125.Final + + + io.netty + netty-codec + 4.1.125.Final + + + com.google.protobuf + protobuf-java + 3.25.5 + + + net.minidev + json-smart + 2.5.2 + + + org.xmlunit + xmlunit-core + 2.10.0 + test + + + org.springframework.boot + spring-boot-starter-validation + + + org.apache.commons + commons-lang3 + 3.18.0 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/src/main/java/com/example/service/conversation/ConversationManagerService.java b/src/main/java/com/example/service/conversation/ConversationManagerService.java index 611f86d..cd646b5 100644 --- a/src/main/java/com/example/service/conversation/ConversationManagerService.java +++ b/src/main/java/com/example/service/conversation/ConversationManagerService.java @@ -63,6 +63,7 @@ public class ConversationManagerService { private static final Logger logger = LoggerFactory.getLogger(ConversationManagerService.class); private static final long SESSION_RESET_THRESHOLD_MINUTES = 30; + private static final long SCREEN_CONTEXT_TIMEOUT_MINUTES = 10; // fix for the quick replies screen private static final String CONV_HISTORY_PARAM = "conversation_history"; private final ExternalConvRequestMapper externalRequestToDialogflowMapper; private final DialogflowClientService dialogflowServiceClient; @@ -123,12 +124,25 @@ public class ConversationManagerService { externalrequest.pantallaContexto()); return memoryStoreConversationService.getSessionByTelefono(externalrequest.user().telefono()) .flatMap(session -> { - if (session != null && session.pantallaContexto() != null - && !session.pantallaContexto().isBlank()) { - logger.info( - "Detected 'pantallaContexto' in session. Delegating to QuickRepliesManagerService."); - return quickRepliesManagerService.manageConversation(obfuscatedRequest); - } + boolean isContextStale = false; + if (session.lastModified() != null) { + long minutesSinceLastUpdate = java.time.Duration.between(session.lastModified(), java.time.Instant.now()).toMinutes(); + if (minutesSinceLastUpdate > SCREEN_CONTEXT_TIMEOUT_MINUTES) { + isContextStale = true; + } + } + + if (session != null && session.pantallaContexto() != null + && !session.pantallaContexto().isBlank() + && !isContextStale) { + logger.info("Detected active 'pantallaContexto'. Delegating to QuickRepliesManagerService."); + return quickRepliesManagerService.manageConversation(obfuscatedRequest); + } + + // Remove the old QR and continue as normal conversation. + if (isContextStale && session.pantallaContexto() != null) { + logger.info("Detected STALE 'pantallaContexto'. Ignoring and proceeding with normal flow."); + } return continueManagingConversation(obfuscatedRequest); }) .switchIfEmpty(continueManagingConversation(obfuscatedRequest)); diff --git a/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java b/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java index 809cde3..1897e32 100644 --- a/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java +++ b/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java @@ -22,7 +22,7 @@ import java.util.stream.IntStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; -import com.example.dto.dialogflow.conversation.QueryResultDTO; + import com.example.service.conversation.ConversationManagerService; import org.springframework.context.annotation.Lazy; import reactor.core.publisher.Mono; @@ -109,16 +109,36 @@ public class QuickRepliesManagerService { userMessagesCount = 0; } - if (userMessagesCount == 0) { // Is the first user message in the Quick-Replies flow - // This is the second message of the flow. Return the full list. - return persistConversationTurn(session, userEntry) - .then(quickReplyContentService.getQuickReplies(session.pantallaContexto())) - .flatMap(quickReplyDTO -> { - ConversationEntryDTO agentEntry = ConversationEntryDTO - .forAgentWithMessage(quickReplyDTO.toString()); - return persistConversationTurn(session, agentEntry) - .thenReturn(new DetectIntentResponseDTO(session.sessionId(), null, quickReplyDTO)); - }); + if (userMessagesCount == 0) { + // El usuario acaba de responder al menú. + // Validamos si lo que escribió coincide con alguna opción del menú. + return quickReplyContentService.getQuickReplies(session.pantallaContexto()) + .flatMap(quickReplyDTO -> { + // Verificamos si el texto del usuario coincide con algún título de las preguntas + boolean esOpcionValida = quickReplyDTO.preguntas().stream() + .anyMatch(p -> p.titulo().equalsIgnoreCase(externalRequest.message().trim())); + + if (esOpcionValida) { + // Si coincide, guardamos el mensaje y procesamos la respuesta (lógica existente para respuesta automática) + String respuesta = quickReplyDTO.preguntas().stream() + .filter(p -> p.titulo().equalsIgnoreCase(externalRequest.message().trim())) + .findFirst().get().respuesta(); + + QueryResultDTO queryResult = new QueryResultDTO(respuesta, null); + DetectIntentResponseDTO response = new DetectIntentResponseDTO(session.sessionId(), queryResult, null); + + return persistConversationTurn(session, userEntry) // Guardamos el "input" del usuario + .then(memoryStoreConversationService.updateSession(session.withPantallaContexto(null))) // Limpiamos contexto + .then(persistConversationTurn(session, ConversationEntryDTO.forAgentWithMessage(respuesta))) // Guardamos la respuesta + .thenReturn(response); + + } else { + // Si el usuario escribe algo que no es del menu valida y manda a Dialog + logger.info("El input del usuario no coincide con las Quick Replies. Redirigiendo a Dialogflow normal."); + return memoryStoreConversationService.updateSession(session.withPantallaContexto(null)) + .then(conversationManagerService.manageConversation(externalRequest)); + } + }); } else if (userMessagesCount == 1) { // Is the second user message in the QR flow // This is the third message of the flow. Filter and end. return persistConversationTurn(session, userEntry) diff --git a/src/test/java/com/example/service/unit_testing/QuickRepliesManagerServiceTest.java b/src/test/java/com/example/service/unit_testing/QuickRepliesManagerServiceTest.java new file mode 100644 index 0000000..d485364 --- /dev/null +++ b/src/test/java/com/example/service/unit_testing/QuickRepliesManagerServiceTest.java @@ -0,0 +1,177 @@ +package com.example.service.unit_testing; + +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.*; +import com.example.dto.quickreplies.QuestionDTO; +import com.example.dto.quickreplies.QuickReplyDTO; +import com.example.mapper.conversation.ConversationEntryMapper; +import com.example.service.conversation.ConversationManagerService; +import com.example.service.conversation.FirestoreConversationService; +import com.example.service.conversation.MemoryStoreConversationService; +import com.example.service.quickreplies.QuickRepliesManagerService; +import com.example.service.quickreplies.QuickReplyContentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class QuickRepliesManagerServiceTest { + + @Mock private MemoryStoreConversationService memoryStoreConversationService; + @Mock private FirestoreConversationService firestoreConversationService; + @Mock private QuickReplyContentService quickReplyContentService; + @Mock private ConversationManagerService conversationManagerService; + @Mock private ConversationEntryMapper conversationEntryMapper; + + private QuickRepliesManagerService quickRepliesManagerService; + + // Test Data + private final String PHONE = "5555555555"; + private final String SESSION_ID = "session-123"; + private final String USER_ID = "user_by_phone_5555555555"; + private final String CONTEXTO = "pagos"; + + @BeforeEach + void setUp() { + quickRepliesManagerService = new QuickRepliesManagerService( + conversationManagerService, + memoryStoreConversationService, + firestoreConversationService, + quickReplyContentService, + conversationEntryMapper + ); + } + + @Test + @DisplayName("manageConversation - Count 0 - NO MATCH: Should clear context and delegate to Dialogflow") + void manageConversation_Count0_NoMatch_ShouldDelegate() { + // 1. SETUP: User typed "Hola", but context "pagos" is active. + ExternalConvRequestDTO request = new ExternalConvRequestDTO("Hola", new UsuarioDTO(PHONE, "Nick"), "whatsapp", ConversationEntryType.CONVERSACION, null); + ConversationSessionDTO session = new ConversationSessionDTO(SESSION_ID, USER_ID, PHONE, Instant.now(), Instant.now(), "last", CONTEXTO); + + // Mock Session Retrieval + when(memoryStoreConversationService.getSessionByTelefono(PHONE)).thenReturn(Mono.just(session)); + + // Mock History: Only the SYSTEM message (The Menu) exists. Count = 0. + ConversationMessageDTO sysMsg = new ConversationMessageDTO(MessageType.SYSTEM, Instant.now(), "Menu...", null, "whatsapp"); + when(memoryStoreConversationService.getMessages(SESSION_ID)).thenReturn(Flux.just(sysMsg)); + + // Mock QR Content: The menu has options, but "Hola" is NOT one of them. + QuestionDTO q1 = new QuestionDTO("Ver Saldo", "desc", "Tu saldo is 10"); + QuickReplyDTO qrDto = new QuickReplyDTO("Header", "Body", "Btn", "Section", List.of(q1)); + when(quickReplyContentService.getQuickReplies(CONTEXTO)).thenReturn(Mono.just(qrDto)); + + // Mock Orchestrator Delegation (The expected outcome) + DetectIntentResponseDTO delegatedResponse = new DetectIntentResponseDTO("df-response", new QueryResultDTO("Hola soy Beto", null)); + when(memoryStoreConversationService.updateSession(any())).thenReturn(Mono.empty()); // Clearing context + when(conversationManagerService.manageConversation(request)).thenReturn(Mono.just(delegatedResponse)); + + // 2. EXECUTE + StepVerifier.create(quickRepliesManagerService.manageConversation(request)) + .expectNext(delegatedResponse) + .verifyComplete(); + + // 3. VERIFY + // Ensure we cleared the context + verify(memoryStoreConversationService).updateSession(argThat(s -> s.pantallaContexto() == null)); + // Ensure we called the normal conversation manager + verify(conversationManagerService).manageConversation(request); + } + + @Test + @DisplayName("manageConversation - Count 0 - MATCH: Should return QR Answer") + void manageConversation_Count0_Match_ShouldAnswer() { + // 1. SETUP: User typed "Ver Saldo" (Valid Option) + ExternalConvRequestDTO request = new ExternalConvRequestDTO("Ver Saldo", new UsuarioDTO(PHONE, "Nick"), "whatsapp", ConversationEntryType.CONVERSACION, null); + ConversationSessionDTO session = new ConversationSessionDTO(SESSION_ID, USER_ID, PHONE, Instant.now(), Instant.now(), "last", CONTEXTO); + + when(memoryStoreConversationService.getSessionByTelefono(PHONE)).thenReturn(Mono.just(session)); + + // Count 0 (Last message was System) + ConversationMessageDTO sysMsg = new ConversationMessageDTO(MessageType.SYSTEM, Instant.now(), "Menu...", null, "whatsapp"); + when(memoryStoreConversationService.getMessages(SESSION_ID)).thenReturn(Flux.just(sysMsg)); + + // Valid Option exists + QuestionDTO q1 = new QuestionDTO("Ver Saldo", "desc", "Tu saldo es 100 pesos"); + QuickReplyDTO qrDto = new QuickReplyDTO("Header", "Body", "Btn", "Section", List.of(q1)); + when(quickReplyContentService.getQuickReplies(CONTEXTO)).thenReturn(Mono.just(qrDto)); + + // Mocks for saving the conversation turn + mockPersistence(); + when(memoryStoreConversationService.updateSession(any())).thenReturn(Mono.empty()); + + // 2. EXECUTE + StepVerifier.create(quickRepliesManagerService.manageConversation(request)) + .assertNext(response -> { + // Expect the pre-defined answer from the JSON + assert response.queryResult().responseText().equals("Tu saldo es 100 pesos"); + }) + .verifyComplete(); + + // Verify we did NOT delegate + verify(conversationManagerService, never()).manageConversation(any()); + } + + @Test + @DisplayName("manageConversation - Count 1 - NO MATCH: Should save User Msg, Clear, and Delegate") + void manageConversation_Count1_NoMatch_ShouldDelegate() { + // 1. SETUP: User typed "Gracias" (Not an option) + ExternalConvRequestDTO request = new ExternalConvRequestDTO("Gracias", new UsuarioDTO(PHONE, "Nick"), "whatsapp", ConversationEntryType.CONVERSACION, null); + ConversationSessionDTO session = new ConversationSessionDTO(SESSION_ID, USER_ID, PHONE, Instant.now(), Instant.now(), "last", CONTEXTO); + + when(memoryStoreConversationService.getSessionByTelefono(PHONE)).thenReturn(Mono.just(session)); + + // Mock History: System msg -> User msg (Invalid) -> Now this is the 2nd user msg (Count 1 logic) + // Wait, logic says count is messages AFTER last init. + // Sys (Init) -> User("Hola" - ignored previously) -> Current Request("Gracias") + ConversationMessageDTO sysMsg = new ConversationMessageDTO(MessageType.SYSTEM, Instant.now(), "Menu...", null, "whatsapp"); + ConversationMessageDTO userMsg1 = new ConversationMessageDTO(MessageType.USER, Instant.now(), "Hola", null, "whatsapp"); + when(memoryStoreConversationService.getMessages(SESSION_ID)).thenReturn(Flux.just(sysMsg, userMsg1)); + + QuestionDTO q1 = new QuestionDTO("Ver Saldo", "desc", "Tu saldo es 10"); + QuickReplyDTO qrDto = new QuickReplyDTO("Header", "Body", "Btn", "Section", List.of(q1)); + when(quickReplyContentService.getQuickReplies(CONTEXTO)).thenReturn(Mono.just(qrDto)); + + // Mock persistence for the user message (Manual save in the else block) + mockPersistence(); + + // Mock Delegation + DetectIntentResponseDTO delegatedResponse = new DetectIntentResponseDTO("df-response", new QueryResultDTO("De nada", null)); + when(memoryStoreConversationService.updateSession(any())).thenReturn(Mono.empty()); + when(conversationManagerService.manageConversation(request)).thenReturn(Mono.just(delegatedResponse)); + + // 2. EXECUTE + StepVerifier.create(quickRepliesManagerService.manageConversation(request)) + .expectNext(delegatedResponse) + .verifyComplete(); + + // 3. VERIFY + // Ensure manual save was called (verifying flow of `persistConversationTurn`) + verify(memoryStoreConversationService, times(1)).saveMessage(eq(SESSION_ID), any()); + verify(memoryStoreConversationService).updateSession(argThat(s -> s.pantallaContexto() == null)); + verify(conversationManagerService).manageConversation(request); + } + + // Helper to mock the complex save chain + private void mockPersistence() { + when(conversationEntryMapper.toConversationMessageDTO(any())).thenReturn(new ConversationMessageDTO(MessageType.USER, Instant.now(), "text", null, "wa")); + when(memoryStoreConversationService.saveSession(any())).thenReturn(Mono.empty()); + when(memoryStoreConversationService.saveMessage(anyString(), any())).thenReturn(Mono.empty()); + when(firestoreConversationService.saveSession(any())).thenReturn(Mono.empty()); + when(firestoreConversationService.saveMessage(anyString(), any())).thenReturn(Mono.empty()); + } +} \ No newline at end of file