Repository: Arquisoft/wichat_es3b
Pull request #171: Api coverage tests
Author: login: UO291047
Created: 2025-04-05T21:53:04Z | Merged: 2025-04-06T07:23:46Z



Diff:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 9ec1dcf15a..39fa760bbb 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -18,11 +18,13 @@ jobs:
     - run: npm --prefix llmservice ci
     - run: npm --prefix gatewayservice ci
     - run: npm --prefix webapp ci
+    - run: npm --prefix apiservice ci
     - run: npm --prefix users/authservice test -- --coverage
     - run: npm --prefix users/userservice test -- --coverage
     - run: npm --prefix llmservice test -- --coverage
     - run: npm --prefix gatewayservice test -- --coverage
     - run: npm --prefix webapp test -- --coverage
+    - run: npm --prefix apiservice test -- --coverage
     - name: Analyze with SonarQube
       uses: SonarSource/sonarqube-scan-action@master
       env:
diff --git a/apiservice/Dockerfile b/apiservice/Dockerfile
index 7717ff884d..26ebf4b266 100644
--- a/apiservice/Dockerfile
+++ b/apiservice/Dockerfile
@@ -10,6 +10,9 @@ COPY package*.json ./
 # Install app dependencies
 RUN npm install
 
+ARG API_URI
+ENV GATEWAY_SERVICE_URL=$API_URI
+
 # Copy the app source code to the working directory
 COPY . .
 
diff --git a/apiservice/api-service.js b/apiservice/api-service.js
index b438ea6e81..0adfd63446 100644
--- a/apiservice/api-service.js
+++ b/apiservice/api-service.js
@@ -5,90 +5,208 @@ const swaggerUi = require("swagger-ui-express");
 const fs = require("fs");
 const YAML = require("yaml");
 const promBundle = require("express-prom-bundle");
+const path = require('path');
 
 const app = express();
-const port = 8006; // Nuevo puerto para el API service
-
-// URL del gateway service
+const port = 8006;
 const gatewayServiceUrl = process.env.GATEWAY_SERVICE_URL || "http://localhost:8000";
 
 app.use(cors());
 app.use(express.json());
 
-// Configuración de Prometheus
+// Mocked in tests, real setup here
 const metricsMiddleware = promBundle({ includeMethod: true });
 app.use(metricsMiddleware);
 
+// --- Helper function for consistent error handling ---
+const handleAxiosError = (error, res) => {
+    // Log the received error structure for debugging if needed
+    // console.error('Handling Axios Error:', JSON.stringify(error, null, 2));
+
+    if (error.response) {
+        // Gateway responded with an error status code
+        const status = error.response.status || 500; // Default to 500 if status is missing
+        const message = error.response.data?.message || error.response.data || 'No additional message from gateway.';
+        console.error(`Gateway Error Response: Status=${status}, Data=${JSON.stringify(error.response.data)}`);
+        res.status(status).json({
+            error: `Gateway Error: ${status}`,
+            message: message
+        });
+    } else if (error.request) {
+        // Request was made but no response received (network error)
+        // Ensure this is a genuine network error, not just a rejected promise without a response
+        console.error('Gateway Connection Error:', error.message);
+        res.status(503).json({
+            error: 'Gateway service unavailable',
+            message: `The request to the gateway failed: ${error.message}` // Include Axios error message
+        });
+    } else {
+        // Something else happened setting up the request or a non-Axios error
+        console.error('Internal Server Error:', error.message, error.stack); // Log stack trace
+        res.status(500).json({
+            error: 'Internal server error',
+            message: error.message || 'An unexpected error occurred.'
+        });
+    }
+};
+
+// --- Routes ---
+
 // Health check endpoint
 app.get("/health", async (req, res) => {
     try {
-        // Verificar el estado del gateway service
+        console.log(`Performing health check towards: ${gatewayServiceUrl}/health`);
         const gatewayHealth = await axios.get(`${gatewayServiceUrl}/health`);
-        res.json({ status: "OK", gateway: gatewayHealth.data });
+        // Ensure gatewayHealth.data exists before accessing it
+        res.status(200).json({ status: "OK", gateway: gatewayHealth?.data || "No data received" });
     } catch (error) {
-        res.status(500).json({
+        // Simplified error handling for health check
+        const errorMessage = error.message || "Unknown error";
+        console.error("Health check failed to reach gateway:", errorMessage);
+        // Provide a more specific message based on error type if possible
+        const responseBody = {
             status: "WARNING",
-            message: "API service is running but gateway service is unavailable",
-            error: error.message
-        });
+            message: "API service is running but could not confirm gateway status.",
+            // Send the raw error message from the underlying error
+            gateway_error: errorMessage
+        };
+        // Determine status based on error type (basic check)
+        if (error.request && !error.response) { // Likely network error
+            res.status(503).json(responseBody); // Use 503 for network issues
+        } else {
+            res.status(500).json(responseBody); // Use 500 for other errors (including gateway 4xx/5xx)
+        }
     }
 });
 
 // Questions endpoint
-app.get('/questions/:n', async (req, res) => {
+app.get('/questions/:n/:topic?', async (req, res) => {
     try {
-        const { n } = req.params;
-        // Redirigir la solicitud al gateway service
-        const response = await axios.get(`${gatewayServiceUrl}/questions/${n}`);
-        res.json(response.data);
+        const { n, topic } = req.params;
+        let queryString = `questions?n=${n}`;
+        if (topic) {
+            queryString += `&topic=${encodeURIComponent(topic)}`;
+        }
+        const response = await axios.get(`${gatewayServiceUrl}/${queryString}`);
+        // Ensure response.data exists before sending
+        res.json(response?.data || {});
     } catch (error) {
-        res.status(error.response ? error.response.status : 500).json({
-            error: error.response ? error.response.data.error : error.message
-        });
+        handleAxiosError(error, res);
     }
 });
 
-// Get user stats endpoint
+// Stats endpoint
 app.get('/getstats/:username', async (req, res) => {
     try {
         const { username } = req.params;
-        // Redirigir la solicitud al gateway service
-        const response = await axios.get(`${gatewayServiceUrl}/getstats/${username}`);
-        res.json(response.data);
+        const requestUrl = `${gatewayServiceUrl}/getstats/${username}`;
+        console.log(`Forwarding request to: ${requestUrl}`);
+        const response = await axios.get(requestUrl);
+        res.json(response?.data || {});
     } catch (error) {
-        res.status(error.response ? error.response.status : 500).json({
-            error: error.response ? error.response.data.error : error.message
-        });
+        handleAxiosError(error, res);
     }
 });
 
-// Cargar y configurar la documentación OpenAPI
-const openapiPath = "./openapi.yaml";
-if (fs.existsSync(openapiPath)) {
-    const file = fs.readFileSync(openapiPath, "utf8");
-    const swaggerDocument = YAML.parse(file);
-
-    // Actualizar la URL del servidor a la del API service
-    swaggerDocument.servers.forEach((server, index) => {
-        if (server.description === "Development server") {
-            swaggerDocument.servers[index].url = `http://localhost:${port}`;
-        } else if (server.description === "Production server") {
-            // Mantener la IP pero cambiar el puerto
-            const url = server.url.split(':');
-            swaggerDocument.servers[index].url = `${url[0]}:${url[1]}:${port}`;
+
+// --- OpenAPI Configuration ---
+let swaggerDocument; // Keep swaggerDocument accessible
+try {
+    const openapiPath = path.join(__dirname, 'openapi.yaml');
+    if (fs.existsSync(openapiPath)) {
+        const file = fs.readFileSync(openapiPath, 'utf8');
+        swaggerDocument = YAML.parse(file); // Assign to the outer variable
+
+        // Dynamically update server URLs based on current environment/port
+        if (swaggerDocument && swaggerDocument.servers && Array.isArray(swaggerDocument.servers)) {
+            swaggerDocument.servers.forEach((server, index) => {
+                try {
+                    const serverUrl = new URL(server.url);
+                    serverUrl.port = port; // Update port
+                    swaggerDocument.servers[index].url = serverUrl.toString();
+                    console.log(`Updated OpenAPI server URL (${server.description || index}) to: ${swaggerDocument.servers[index].url}`);
+                } catch (urlError) {
+                    console.error(`Could not parse or update server URL: ${server.url} - ${urlError.message}`);
+                }
+            });
+        } else {
+            console.log("No valid 'servers' array found in OpenAPI document to update.");
+        }
+
+        // Serve the updated documentation ONLY if swaggerDocument is valid
+        if (swaggerDocument) {
+            app.use("/api-doc", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
+            console.log(`API Documentation available at http://localhost:${port}/api-doc`);
+        } else {
+            throw new Error("Parsed Swagger document is invalid.");
         }
-    });
 
-    // Servir la documentación en la ruta /api-doc
-    app.use("/api-doc", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
-} else {
-    console.log("No se encontró el archivo de configuración OpenAPI.");
+    } else {
+        console.log(`OpenAPI configuration file not found at: ${openapiPath}`);
+        // Add a placeholder route if file not found
+        app.get("/api-doc", (req, res) => {
+            res.status(404).send("API Documentation configuration file not found.");
+        });
+    }
+} catch (err) {
+    console.error(`Error configuring OpenAPI: ${err.message}`);
+    app.get("/api-doc", (req, res) => {
+        res.status(500).send("API Documentation could not be loaded due to a configuration error.");
+    });
 }
 
-// Iniciar el servicio API
-const server = app.listen(port, () => {
-    console.log(`API Service listening at http://localhost:${port}`);
-    console.log(`API Documentation available at http://localhost:${port}/api-doc`);
-});
+// --- Server Start ---
+let server;
 
-module.exports = server;
\ No newline at end of file
+module.exports = {
+    app,
+    listen: (callback) => {
+        // Prevent multiple listeners
+        if (server && server.listening) {
+            console.log(`Server already listening on port ${port}`);
+            if (callback) process.nextTick(callback); // Ensure callback runs async
+            return server;
+        }
+        server = app.listen(port, () => {
+            console.log(`API Service listening at http://localhost:${port}`);
+            if (callback) callback();
+        });
+        server.on('error', (err) => {
+            console.error("Server failed to start:", err);
+            server = null; // Reset server variable on error
+            // Don't exit process in tests, maybe re-throw or handle differently
+            // process.exit(1);
+        });
+        return server;
+    },
+    close: (callback) => {
+        return new Promise((resolve, reject) => {
+            if (server && server.listening) {
+                console.log("Closing server...");
+                server.close((err) => {
+                    if (err) {
+                        console.error("Error closing server:", err);
+                        if (callback) callback(err);
+                        return reject(err);
+                    }
+                    console.log("Server closed.");
+                    server = null; // Reset server variable
+                    if (callback) callback();
+                    resolve();
+                });
+            } else {
+                console.log("Server not running or already closed.");
+                if (callback) process.nextTick(callback);
+                resolve(); // Resolve immediately if not running
+            }
+        });
+    },
+    // Expose the potentially modified swagger doc for testing if needed
+    // Note: This is generally not ideal, prefer testing via endpoints
+    _getSwaggerDoc: () => swaggerDocument
+};
+
+// --- Auto-start logic ---
+if (require.main === module && process.env.NODE_ENV !== 'test') {
+    module.exports.listen();
+}
diff --git a/apiservice/api-service.test.js b/apiservice/api-service.test.js
new file mode 100644
index 0000000000..4d4c3652bb
--- /dev/null
+++ b/apiservice/api-service.test.js
@@ -0,0 +1,434 @@
+const request = require("supertest");
+// Import axios BEFORE mocking
+const axios = require("axios");
+const fs = require("fs");
+const YAML = require("yaml");
+const path = require('path');
+
+// --- Mocking Modules ---
+// Mock entire modules used by api-service
+jest.mock("axios");
+jest.mock("fs");
+jest.mock("yaml");
+jest.mock("express-prom-bundle", () => jest.fn(() => (req, res, next) => next()));
+// Mock swagger-ui-express to prevent actual UI setup
+jest.mock("swagger-ui-express", () => ({
+    serve: [],
+    setup: () => (req, res) => {
+        res.status(200).send("Swagger UI");
+    }
+}));
+
+// --- Global Test Variables ---
+let currentApiService; // Holds the required module exports { app, listen, close, ... }
+let currentApp;      // Holds the express app instance for supertest
+let currentServer;   // Holds the server instance returned by listen() for closing
+const defaultGatewayUrl = "http://localhost:8000";
+
+// Function to setup default mocks for fs/YAML (used after reset)
+const setupDefaultFsYamlMocks = () => {
+    fs.existsSync.mockReturnValue(true);
+    fs.readFileSync.mockReturnValue(`
+openapi: 3.0.0
+info:
+  title: Mock API
+  version: 1.0.0
+servers:
+  - url: http://localhost:8006
+    description: Development server
+  - url: https://production-api.example.com:443
+    description: Production server
+paths: {}
+`);
+    const mockSwaggerDoc = {
+        openapi: '3.0.0',
+        info: { title: 'Mock API', version: '1.0.0' },
+        servers: [
+            { url: 'http://localhost:8006', description: 'Development server' },
+            { url: 'https://production-api.example.com:443', description: 'Production server' }
+        ],
+        paths: {}
+    };
+    YAML.parse.mockReturnValue(mockSwaggerDoc);
+};
+
+// --- Test Suite ---
+describe("API Service", () => {
+    // Store originals
+    const originalConsoleLog = console.log;
+    const originalConsoleError = console.error;
+    const originalGatewayUrlEnv = process.env.GATEWAY_SERVICE_URL;
+    const originalPortEnv = process.env.PORT;
+    const originalNodeEnv = process.env.NODE_ENV;
+
+    beforeAll(() => {
+        // Suppress console logs/errors unless debugging
+        // console.log = jest.fn();
+        // console.error = jest.fn();
+    });
+
+    afterAll(() => {
+        // Restore original console and environment variables
+        console.log = originalConsoleLog;
+        console.error = originalConsoleError;
+        process.env.GATEWAY_SERVICE_URL = originalGatewayUrlEnv;
+        process.env.PORT = originalPortEnv;
+        process.env.NODE_ENV = originalNodeEnv;
+    });
+
+    // Setup and teardown for most tests (those not needing module reload)
+    beforeEach(async () => {
+        // Reset mocks for isolation BEFORE requiring the service
+        jest.resetAllMocks();
+        setupDefaultFsYamlMocks(); // Apply default fs/YAML mocks
+
+        // Require the service (uses default env vars)
+        currentApiService = require("./api-service");
+        currentApp = currentApiService.app;
+        // Start the server explicitly if needed by tests (supertest often doesn't need it listening)
+        // currentServer = await currentApiService.listen(); // Optional: Start server if tests require it
+    });
+
+    afterEach(async () => {
+        // Close the server if it was started
+        if (currentApiService && typeof currentApiService.close === 'function') {
+            // Check if server instance exists before closing
+            // This check might be needed depending on whether beforeEach started the server
+            // if (currentServer && currentServer.listening) {
+            await currentApiService.close().catch(err => console.error("Error during afterEach close:", err));
+            // }
+        }
+        currentApiService = null;
+        currentApp = null;
+        currentServer = null;
+        // Clear only mock call history, not implementations set in beforeEach/test
+        axios.get.mockClear();
+        fs.existsSync.mockClear();
+        fs.readFileSync.mockClear();
+        YAML.parse.mockClear();
+    });
+
+
+    // --- Test Cases ---
+
+    describe("Basic Setup and Health Check", () => {
+        it("should initialize the server and respond to basic requests", async () => {
+            // App is loaded in beforeEach
+            const response = await request(currentApp).get("/nonexistentpath");
+            expect(response.status).toBe(404);
+        });
+
+        it("should call the gateway health endpoint on /health and return 200 on success", async () => {
+            const mockGatewayHealth = { status: "GATEWAY_OK", timestamp: Date.now() };
+            // Set the specific mock for THIS test
+            axios.get.mockResolvedValueOnce({ data: mockGatewayHealth });
+
+            const response = await request(currentApp).get("/health");
+
+            expect(response.status).toBe(200);
+            // Check the response body structure matches the expected output
+            expect(response.body).toEqual({ status: "OK", gateway: mockGatewayHealth });
+            expect(axios.get).toHaveBeenCalledTimes(1);
+            expect(axios.get).toHaveBeenCalledWith(`${defaultGatewayUrl}/health`);
+        });
+
+        it("should handle gateway unavailability correctly during /health check (return 503)", async () => {
+            const networkError = new Error("ECONNREFUSED Network Error");
+            networkError.request = {}; // Simulate connection error
+            // Set the specific mock for THIS test
+            axios.get.mockRejectedValueOnce(networkError);
+
+            const response = await request(currentApp).get("/health");
+
+            expect(response.status).toBe(503); // Expect 503 based on service logic
+            expect(response.body.status).toBe("WARNING");
+            expect(response.body.message).toContain("could not confirm gateway status");
+            expect(response.body.gateway_error).toBe("ECONNREFUSED Network Error");
+            expect(axios.get).toHaveBeenCalledTimes(1);
+            expect(axios.get).toHaveBeenCalledWith(`${defaultGatewayUrl}/health`);
+        });
+
+        it("should handle other gateway errors during /health check (return 500)", async () => {
+            const gatewayError = { response: { status: 502, data: "Bad Gateway" } };
+            // Set the specific mock for THIS test
+            axios.get.mockRejectedValueOnce(gatewayError);
+
+            const response = await request(currentApp).get("/health");
+
+            expect(response.status).toBe(500); // Expect 500 for non-network errors in health
+            expect(response.body.status).toBe("WARNING");
+            expect(response.body.message).toContain("could not confirm gateway status");
+            expect(response.body.gateway_error).toBeDefined(); // Raw error message might be complex
+            expect(axios.get).toHaveBeenCalledTimes(1);
+            expect(axios.get).toHaveBeenCalledWith(`${defaultGatewayUrl}/health`);
+        });
+    });
+
+    describe("Questions Endpoint", () => {
+        it("should forward questions request with correct parameters (no topic) and return 200", async () => {
+            const mockQuestions = [{ id: 1, text: "Question 1?" }];
+            // Set the specific mock for THIS test
+            axios.get.mockResolvedValueOnce({ data: mockQuestions });
+
+            const response = await request(currentApp).get("/questions/5");
+
+            expect(response.status).toBe(200);
+            expect(response.body).toEqual(mockQuestions); // Expect the actual questions array
+            expect(axios.get).toHaveBeenCalledTimes(1);
+            expect(axios.get).toHaveBeenCalledWith(`${defaultGatewayUrl}/questions?n=5`);
+        });
+
+        it("should forward questions request with topic parameter and return 200", async () => {
+            const mockHistoryQuestions = [{ id: 2, text: "History Q?" }];
+            // Set the specific mock for THIS test
+            axios.get.mockResolvedValueOnce({ data: mockHistoryQuestions });
+
+            const response = await request(currentApp).get("/questions/10/history");
+
+            expect(response.status).toBe(200);
+            expect(response.body).toEqual(mockHistoryQuestions); // Expect the actual questions array
+            expect(axios.get).toHaveBeenCalledTimes(1);
+            expect(axios.get).toHaveBeenCalledWith(`${defaultGatewayUrl}/questions?n=10&topic=history`);
+        });
+
+        it("should handle gateway response error (e.g., 404) correctly", async () => {
+            const errorResponse = {
+                message: "Request failed with status code 404",
+                response: { status: 404, data: { message: 'Not Found from Gateway - Test' } },
+                isAxiosError: true, request: {}, config: {}
+            };
+            // Set the specific mock for THIS test
+            axios.get.mockRejectedValueOnce(errorResponse);
+
+            const response = await request(currentApp).get("/questions/99/nonexistent");
+
+            expect(response.status).toBe(404); // Expect 404
+            expect(response.body.error).toContain("Gateway Error: 404");
+            expect(response.body.message).toBe('Not Found from Gateway - Test');
+            expect(axios.get).toHaveBeenCalledTimes(1);
+            expect(axios.get).toHaveBeenCalledWith(`${defaultGatewayUrl}/questions?n=99&topic=nonexistent`);
+        });
+
+        it("should handle gateway connection error correctly (return 503)", async () => {
+            const connectionError = new Error("ECONNREFUSED connection refused");
+            connectionError.request = {};
+            connectionError.isAxiosError = true; config: {};
+            // Set the specific mock for THIS test
+            axios.get.mockRejectedValueOnce(connectionError);
+
+            const response = await request(currentApp).get("/questions/5/any");
+
+            expect(response.status).toBe(503); // Expect 503
+            expect(response.body.error).toBe("Gateway service unavailable");
+            expect(response.body.message).toContain("ECONNREFUSED connection refused");
+            expect(axios.get).toHaveBeenCalledTimes(1);
+            expect(axios.get).toHaveBeenCalledWith(`${defaultGatewayUrl}/questions?n=5&topic=any`);
+        });
+
+        it("should handle non-axios errors correctly (return 500)", async () => {
+            const genericError = new Error("Something broke unexpectedly");
+            // Set the specific mock for THIS test
+            axios.get.mockRejectedValueOnce(genericError);
+
+            const response = await request(currentApp).get("/questions/5/any");
+
+            expect(response.status).toBe(500); // Expect 500
+            expect(response.body.error).toBe("Internal server error");
+            expect(response.body.message).toBe("Something broke unexpectedly");
+            expect(axios.get).toHaveBeenCalledTimes(1);
+            expect(axios.get).toHaveBeenCalledWith(`${defaultGatewayUrl}/questions?n=5&topic=any`);
+        });
+    });
+
+    // --- Tests requiring module reload ---
+    // Use describe.each or separate describes if preferred
+    describe("OpenAPI Configuration with Module Reload", () => {
+        // These tests need careful setup/teardown for module reloading
+
+        let serviceForTest;
+        let appForTest;
+        const originalPort = process.env.PORT;
+
+        afterEach(async () => {
+            // Ensure server is closed and env vars restored
+            if (serviceForTest && typeof serviceForTest.close === 'function') {
+                await serviceForTest.close().catch(err => console.error("Error closing serviceForTest:", err));
+            }
+            serviceForTest = null;
+            appForTest = null;
+            process.env.PORT = originalPort; // Restore original port
+            // Reset modules again to not affect subsequent non-reload tests
+            jest.resetModules();
+        });
+
+        it("should serve OpenAPI documentation at /api-doc", async () => {
+            // Reset, mock, require
+            jest.resetModules();
+
+            // Mock necessary modules
+            jest.mock("axios");
+            jest.mock("express-prom-bundle", () => jest.fn(() => (req, res, next) => next()));
+
+            // Simular el middleware de swagger-ui-express con mayor precisión
+            // La clave es que mockSwaggerSetup debe devolver un middleware que responde con 200
+            const mockMiddleware = (req, res) => {
+                res.status(200).send("Swagger UI");
+            };
+            const mockSwaggerSetup = jest.fn().mockReturnValue(mockMiddleware);
+
+            jest.mock("swagger-ui-express", () => ({
+                serve: [(req, res, next) => next()], // Middleware array que solo pasa al siguiente
+                setup: mockSwaggerSetup
+            }));
+
+            // Configurar mocks de fs y YAML
+            const fs = require('fs');
+            jest.mock('fs');
+            fs.existsSync = jest.fn().mockReturnValue(true);
+            fs.readFileSync = jest.fn().mockReturnValue('valid yaml content');
+
+            // Asegurarnos de que YAML.parse devuelva un objeto válido
+            const YAML = require('yaml');
+            jest.mock('yaml');
+            YAML.parse = jest.fn().mockReturnValue({
+                openapi: '3.0.0',
+                info: { title: 'Mock API', version: '1.0.0' },
+                servers: [
+                    { url: 'http://localhost:8006', description: 'Development server' }
+                ],
+                paths: {}
+            });
+
+            // Importar el servicio de API después de configurar todos los mocks
+            const serviceForTest = require("./api-service");
+            const appForTest = serviceForTest.app;
+
+            // Verificar que el middleware de Swagger haya sido configurado en app
+            // Enviar una solicitud a /api-doc
+            const response = await request(appForTest).get("/api-doc");
+
+            // Verificar que la respuesta sea correcta
+            expect(response.status).toBe(200);
+            expect(response.text).toContain("Swagger UI");
+
+            // Verificar que mockSwaggerSetup fue llamado durante la carga del módulo
+            expect(mockSwaggerSetup).toHaveBeenCalled();
+        });
+
+        it("should handle OpenAPI configuration file not found (return 404)", async () => {
+            // Reset modules first
+            jest.resetModules();
+
+            // --- Mock fs specifically for this scenario ---
+            const mockFs = {
+                existsSync: jest.fn().mockReturnValue(false), // File doesn't exist
+                readFileSync: jest.fn(), // Should not be called
+            };
+            jest.mock('fs', () => mockFs);
+            jest.mock("axios"); // Need to mock other dependencies too
+            jest.mock("yaml");
+            jest.mock("express-prom-bundle", () => jest.fn(() => (req, res, next) => next()));
+            // --- End Mock fs ---
+
+            serviceForTest = require("./api-service"); // Reload service with the specific mock
+            appForTest = serviceForTest.app;
+
+            expect(mockFs.existsSync).toHaveBeenCalledWith(expect.stringContaining('openapi.yaml'));
+            expect(mockFs.readFileSync).not.toHaveBeenCalled();
+
+            const response = await request(appForTest).get("/api-doc");
+            expect(response.status).toBe(404);
+            expect(response.text).toContain("API Documentation configuration file not found");
+        });
+
+        it("should handle errors during OpenAPI YAML parsing (return 500)", async () => {
+            // Reset modules first
+            jest.resetModules();
+
+            // --- Mock fs and YAML specifically ---
+            const mockFs = {
+                existsSync: jest.fn().mockReturnValue(true),
+                readFileSync: jest.fn().mockReturnValue("invalid: yaml: content"),
+            };
+            const mockYaml = {
+                parse: jest.fn().mockImplementation(() => { throw new Error("Mock YAML Parse Error"); }),
+            };
+            jest.mock('fs', () => mockFs);
+            jest.mock('yaml', () => mockYaml);
+            jest.mock("axios");
+            jest.mock("express-prom-bundle", () => jest.fn(() => (req, res, next) => next()));
+            // --- End Mocks ---
+
+            serviceForTest = require("./api-service"); // Reload
+            appForTest = serviceForTest.app;
+
+            expect(mockFs.existsSync).toHaveBeenCalled();
+            expect(mockFs.readFileSync).toHaveBeenCalled();
+            expect(mockYaml.parse).toHaveBeenCalled();
+
+            const response = await request(appForTest).get("/api-doc");
+            expect(response.status).toBe(500);
+            expect(response.text).toContain("API Documentation could not be loaded");
+        });
+    });
+
+    describe("Environment Variable Usage with Module Reload", () => {
+        let serviceForTest;
+        let appForTest;
+        const originalGatewayUrl = process.env.GATEWAY_SERVICE_URL;
+
+        afterEach(async () => {
+            // Ensure server is closed and env vars restored
+            if (serviceForTest && typeof serviceForTest.close === 'function') {
+                await serviceForTest.close().catch(err => console.error("Error closing serviceForTest:", err));
+            }
+            serviceForTest = null;
+            appForTest = null;
+            process.env.GATEWAY_SERVICE_URL = originalGatewayUrl; // Restore original GW URL
+            // Reset modules again
+            jest.resetModules();
+        });
+
+        it("should use custom gateway URL from environment variable", async () => {
+            const customGatewayUrl = "http://custom-gateway:9000";
+            process.env.GATEWAY_SERVICE_URL = customGatewayUrl; // Set BEFORE reset/require
+
+            // Reset modules, re-apply mocks, re-require service
+            jest.resetModules();
+            jest.mock("axios"); // Crucial: Re-mock axios AFTER reset
+            setupDefaultFsYamlMocks(); // Re-apply other mocks if needed
+            jest.mock("express-prom-bundle", () => jest.fn(() => (req, res, next) => next())); // Re-mock promBundle
+
+            // Now require the service, it will pick up the new env var
+            serviceForTest = require("./api-service");
+            appForTest = serviceForTest.app;
+
+            // Configure the axios mock AFTER requiring the service and getting the app
+            const mockHealthData = { status: "CUSTOM_GATEWAY_OK" };
+            // IMPORTANT: Access the mocked axios directly, not via an old import
+            const mockedAxios = require("axios");
+            mockedAxios.get.mockResolvedValueOnce({ data: mockHealthData });
+
+            // Make the request
+            await request(appForTest).get("/health");
+
+            // Verify axios was called with the CUSTOM URL
+            expect(mockedAxios.get).toHaveBeenCalledTimes(1); // Check call count on the fresh mock
+            expect(mockedAxios.get).toHaveBeenCalledWith(`${customGatewayUrl}/health`); // Check URL
+        });
+    });
+
+    describe("CORS Configuration", () => {
+        // This test doesn't need module reload, uses standard beforeEach/afterEach
+        it("should include CORS headers in the response", async () => {
+            // Axios mock needed for the underlying /health call
+            axios.get.mockResolvedValueOnce({ data: { status: "GATEWAY_OK" } });
+
+            const response = await request(currentApp)
+                .get("/health")
+                .set("Origin", "http://example.com");
+
+            expect(response.headers["access-control-allow-origin"]).toBe("*");
+        });
+    });
+});
\ No newline at end of file
diff --git a/apiservice/openapi.yaml b/apiservice/openapi.yaml
index 7e87cde3b0..a7f4d973cf 100644
--- a/apiservice/openapi.yaml
+++ b/apiservice/openapi.yaml
@@ -22,7 +22,7 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/HealthStatus'
-  /questions/{n}:
+  /questions/{n}/{topic}:
     get:
       summary: Get n questions
       operationId: getQuestions
@@ -35,6 +35,12 @@ paths:
           schema:
             type: integer
           description: Number of questions to retrieve
+        - name: topic
+          in: path
+          required: true
+          schema:
+            type: string
+          description: Topic of the questions (e.g., "all", "history", "history,geography")
       responses:
         '200':
           description: Questions retrieved successfully
diff --git a/llmservice/llm-service.js b/llmservice/llm-service.js
index 1e0e1c61c9..aec2ad659d 100644
--- a/llmservice/llm-service.js
+++ b/llmservice/llm-service.js
@@ -6,6 +6,9 @@ require("dotenv").config();
 const app = express();
 const port = 8003;
 
+let backupModel = 'mistral'; // Modelo de respaldo
+let currentModel = 'empathy'; // Modelo actual
+
 // Middleware to parse JSON in request body
 app.use(express.json());
 
@@ -43,6 +46,21 @@ const llmConfigs = {
             Authorization: `Bearer ${apiKey}`,
             'Content-Type': 'application/json'
         })
+    },
+    mistral: {
+        url: () => 'https://empathyai.prod.empathy.co/v1/chat/completions',
+        transformRequest: (systemPrompt, userQuestion) => ({
+            model: "mistralai/Mistral-7B-Instruct-v0.3",
+            messages: [
+                { role: "system", content: systemPrompt },
+                { role: "user", content: userQuestion }
+            ]
+        }),
+        transformResponse: (response) => response.data.choices[0]?.message?.content,
+        headers: (apiKey) => ({
+            Authorization: `Bearer ${apiKey}`,
+            'Content-Type': 'application/json'
+        })
     }
 };
 
@@ -65,7 +83,7 @@ function validateRequiredFields(req) {
 }
 
 // Generic function to send questions to LLM
-async function sendQuestionToLLM(systemPrompt, userQuestion, apiKey, model = 'gemini') {
+async function sendQuestionToLLM(systemPrompt, userQuestion, apiKey, model = currentModel) {
     try {
         console.log(`Sending request to ${model} LLM...`);
 
@@ -93,7 +111,20 @@ async function sendQuestionToLLM(systemPrompt, userQuestion, apiKey, model = 'ge
         if (error.response) {
             console.error(`LLM respondió con estado ${error.response.status}:`, error.response.data);
         }
-        throw new Error(`Error al obtener respuesta de ${model}`);
+
+        if (error.response && error.response.status >= 500) {
+            console.log(`Error 500 o similar con el modelo ${model}, intentando con el modelo ` + backupModel + `...`);
+            currentModel = backupModel;
+            backupModel = model;
+            return await sendQuestionToLLM(systemPrompt, userQuestion, apiKey, currentModel);
+        }
+        console.error(`Error al enviar pregunta a ${model}:`, error.message || error);
+        if (error.response) {
+            console.error(`LLM respondió con estado ${error.response.status}:`, error.response.data);
+        }
+
+        // Propagamos el error original en lugar de crear uno nuevo
+        throw error;
     }
 }
 
@@ -165,7 +196,7 @@ app.post('/ask', async (req, res) => {
 
         validateRequiredFields(req);
 
-        const { userQuestion, question, idioma, model = 'empathy' } = req.body;
+        const { userQuestion, question, idioma, model = currentModel } = req.body;
 
         // Verificar que tenemos la API key
         if (!process.env.LLM_API_KEY) {
@@ -186,9 +217,43 @@ app.post('/ask', async (req, res) => {
     }
 });
 
-const server = app.listen(port, () => {
-    console.log(`LLM Service listening at http://localhost:${port}`);
-});
+// Variable para almacenar la instancia del servidor
+let server;
 
-module.exports = server;
+// Función para iniciar el servidor con un puerto específico (útil para tests)
+function startServer(testPort) {
+    // Si ya hay un servidor en ejecución, no iniciar otro
+    if (server) {
+        return server;
+    }
+
+    // Usar el puerto de prueba si se proporciona, de lo contrario usar el puerto configurado
+    const serverPort = testPort || port;
+    server = app.listen(serverPort);
+    console.log(`LLM Service listening at http://localhost:${serverPort}`);
+    return server;
+}
+
+// Función para cerrar el servidor
+function closeServer() {
+    if (server) {
+        return new Promise((resolve) => {
+            server.close(() => {
+                server = null;
+                resolve();
+            });
+        });
+    }
+    return Promise.resolve();
+}
+
+// Si este archivo se ejecuta directamente, inicia el servidor
+if (require.main === module) {
+    startServer();
+}
 
+module.exports = {
+    app,
+    startServer,
+    closeServer
+};
\ No newline at end of file
diff --git a/llmservice/llm-service.test.js b/llmservice/llm-service.test.js
index 3fdc4f06ba..2f826efef1 100644
--- a/llmservice/llm-service.test.js
+++ b/llmservice/llm-service.test.js
@@ -1,10 +1,18 @@
 const request = require("supertest");
 const axios = require("axios");
-const app = require("./llm-service");
+const { app, startServer, closeServer } = require("./llm-service");
+
+let server;
+
+// Iniciar el servidor antes de todas las pruebas con un puerto diferente para tests
+beforeAll(async () => {
+  // Usar un puerto diferente para las pruebas
+  server = startServer(8090);
+});
 
 // Cerrar el servidor después de todas las pruebas
 afterAll(async () => {
-  app.close();
+  await closeServer();
 });
 
 // Mockear axios para simular respuestas de servicios externos
@@ -29,14 +37,14 @@ describe("LLM Service", () => {
         return Promise.resolve({
           status: 200,
           data: {
-            candidates: [{ content: { parts: [{ text: "llmanswer" }] } }],
+            candidates: [{ content: { parts: [{ text: "gemini-answer" }] } }],
           },
         });
       } else if (url.startsWith("https://empathyai")) {
         return Promise.resolve({
           status: 200,
           data: {
-            choices: [{ message: { content: "llmanswer" } }],
+            choices: [{ message: { content: "empathy-answer" } }],
           },
         });
       }
@@ -53,8 +61,73 @@ describe("LLM Service", () => {
     expect(response.body.status).toBe("OK");
   });
 
-  // Test del endpoint /ask con los parámetros correctos
-  it("should process ask request with valid parameters", async () => {
+  // Test del endpoint /ask con los parámetros correctos usando empathy
+  it("should process ask request with valid parameters using empathy model", async () => {
+    const testQuestion = {
+      respuestaCorrecta: "El Big Ben",
+      pregunta: {
+        es: "¿Cuál es este monumento?",
+        en: "What is this monument?",
+        idioma: "¿Cuál es este monumento?",
+      },
+      descripcion: [
+        { propiedad: "Capital", valor: "Londres" },
+        { propiedad: "Monarca del país", valor: "Carlos II" },
+      ],
+    };
+
+    const response = await request(app).post("/ask").send({
+      userQuestion: "¿Es la torre eiffel?",
+      question: testQuestion,
+      idioma: "es",
+      model: "empathy",
+    });
+
+    expect(response.statusCode).toBe(200);
+    expect(response.body.answer).toBe("empathy-answer");
+  });
+
+  // Test del endpoint /ask con los parámetros correctos usando gemini
+  it("should process ask request with valid parameters using gemini model", async () => {
+    const testQuestion = {
+      respuestaCorrecta: "El Big Ben",
+      pregunta: {
+        es: "¿Cuál es este monumento?",
+        en: "What is this monument?",
+        idioma: "¿Cuál es este monumento?",
+      },
+      descripcion: [
+        { propiedad: "Capital", valor: "Londres" },
+        { propiedad: "Monarca del país", valor: "Carlos II" },
+      ],
+    };
+
+    const response = await request(app).post("/ask").send({
+      userQuestion: "¿Es la torre eiffel?",
+      question: testQuestion,
+      idioma: "es",
+      model: "gemini",
+    });
+
+    expect(response.statusCode).toBe(200);
+    expect(response.body.answer).toBe("gemini-answer");
+  });
+
+  // Test para verificar manejo cuando el LLM responde vacío
+  it("should handle empty LLM responses", async () => {
+    console.error = jest.fn();
+    console.log = jest.fn();
+
+    // Modificar el mock para simular una respuesta vacía
+    axios.post.mockImplementationOnce(() => {
+      return Promise.resolve({
+        status: 200,
+        data: {
+          choices: [] // Respuesta vacía
+        }
+      });
+    });
+
     const testQuestion = {
       respuestaCorrecta: "El Big Ben",
       pregunta: {
@@ -76,7 +149,124 @@ describe("LLM Service", () => {
     });
 
     expect(response.statusCode).toBe(200);
-    expect(response.body.answer).toBe("llmanswer");
+    expect(response.body.answer).toBe("No se recibió respuesta del LLM");
+  });
+
+  // Test para verificar modelo no soportado
+  it("should return error for unsupported model", async () => {
+    console.error = jest.fn();
+    console.log = jest.fn();
+
+    const testQuestion = {
+      respuestaCorrecta: "El Big Ben",
+      pregunta: {
+        es: "¿Cuál es este monumento?",
+        en: "What is this monument?",
+        idioma: "¿Cuál es este monumento?",
+      },
+      descripcion: [
+        { propiedad: "Capital", valor: "Londres" },
+        { propiedad: "Monarca del país", valor: "Carlos II" },
+      ],
+    };
+
+    const response = await request(app).post("/ask").send({
+      userQuestion: "¿Es la torre eiffel?",
+      question: testQuestion,
+      idioma: "es",
+      model: "unsupported-model", // Modelo no soportado
+    });
+
+    expect(response.statusCode).toBe(400);
+    // Cambiamos la expectativa para que coincida con el mensaje real
+    expect(response.body.error).toBe('Modelo "unsupported-model" no soportado.');
+  });
+
+  // Test para verificar errores de API
+  it("should handle API errors", async () => {
+    console.error = jest.fn();
+    console.log = jest.fn();
+
+    // Modificar el mock para simular un error API
+    axios.post.mockImplementationOnce(() => {
+      return Promise.reject({
+        response: {
+          status: 403,
+          data: { error: "API Error" }
+        }
+      });
+    });
+
+    const testQuestion = {
+      respuestaCorrecta: "El Big Ben",
+      pregunta: {
+        es: "¿Cuál es este monumento?",
+        en: "What is this monument?",
+        idioma: "¿Cuál es este monumento?",
+      },
+      descripcion: [
+        { propiedad: "Capital", valor: "Londres" },
+        { propiedad: "Monarca del país", valor: "Carlos II" },
+      ],
+    };
+
+    const response = await request(app).post("/ask").send({
+      userQuestion: "¿Es la torre eiffel?",
+      question: testQuestion,
+      idioma: "es",
+      model: "empathy",
+    });
+
+    expect(response.statusCode).toBe(400);
+    expect(response.body.error).toBeTruthy();
+  });
+
+  // Test para verificar el middleware de logging
+  it("should log request info with middleware", async () => {
+    console.log = jest.fn();
+
+    await request(app).get("/");
+
+    // Verificar que se llamó a console.log (el middleware de logging)
+    expect(console.log).toHaveBeenCalled();
+    const logCall = console.log.mock.calls[0][0];
+    expect(logCall).toContain("GET /");
+  });
+
+  // Test verificando errores cuando falta API key
+  it("should return error when LLM_API_KEY is missing", async () => {
+    console.error = jest.fn();
+    console.log = jest.fn();
+
+    // Guardamos la API key actual
+    const originalApiKey = process.env.LLM_API_KEY;
+    // La eliminamos temporalmente
+    delete process.env.LLM_API_KEY;
+
+    const testQuestion = {
+      respuestaCorrecta: "El Big Ben",
+      pregunta: {
+        es: "¿Cuál es este monumento?",
+        en: "What is this monument?",
+        idioma: "¿Cuál es este monumento?",
+      },
+      descripcion: [
+        { propiedad: "Capital", valor: "Londres" },
+        { propiedad: "Monarca del país", valor: "Carlos II" },
+      ],
+    };
+
+    const response = await request(app).post("/ask").send({
+      userQuestion: "¿Es la torre eiffel?",
+      question: testQuestion,
+      idioma: "es",
+    });
+
+    expect(response.statusCode).toBe(400);
+    expect(response.body.error).toContain("LLM_API_KEY no está configurada");
+
+    // Restauramos la API key
+    process.env.LLM_API_KEY = originalApiKey;
   });
 
   // Test de error por falta de parámetros requeridos
@@ -89,8 +279,9 @@ describe("LLM Service", () => {
 
     expect(response.statusCode).toBe(400);
     expect(response.body.error).toBeTruthy();
+    expect(response.body.error).toContain("Campo requerido faltante");
 
-    // Verificar que se llamó a console.error (pero no veremos el mensaje)
+    // Verificar que se llamó a console.error
     expect(console.error).toHaveBeenCalled();
   });
 
@@ -102,17 +293,50 @@ describe("LLM Service", () => {
 
     const response = await request(app).post("/ask").send({
       userQuestion: "¿Es la torre eiffel?",
-      question: {
-        /* estructura incorrecta */
-      },
+      question: { /* estructura incorrecta */ },
       idioma: "es",
       model: "empathy",
     });
 
     expect(response.statusCode).toBe(400);
     expect(response.body.error).toBeTruthy();
+    expect(response.body.error).toContain("Estructura de question inválida");
 
-    // Verificar que se llamó a console.error (pero no veremos el mensaje)
+    // Verificar que se llamó a console.error
     expect(console.error).toHaveBeenCalled();
   });
+
+  // Test para error genérico sin objeto de respuesta
+  it("should handle generic errors without response object", async () => {
+    console.error = jest.fn();
+    console.log = jest.fn();
+
+    // Modificar el mock para simular un error genérico
+    axios.post.mockImplementationOnce(() => {
+      return Promise.reject(new Error("Network error"));
+    });
+
+    const testQuestion = {
+      respuestaCorrecta: "El Big Ben",
+      pregunta: {
+        es: "¿Cuál es este monumento?",
+        en: "What is this monument?",
+        idioma: "¿Cuál es este monumento?",
+      },
+      descripcion: [
+        { propiedad: "Capital", valor: "Londres" },
+        { propiedad: "Monarca del país", valor: "Carlos II" },
+      ],
+    };
+
+    const response = await request(app).post("/ask").send({
+      userQuestion: "¿Es la torre eiffel?",
+      question: testQuestion,
+      idioma: "es",
+      model: "empathy",
+    });
+
+    expect(response.statusCode).toBe(400);
+    expect(response.body.error).toBe("Network error");
+  });
 });
diff --git a/package-lock.json b/package-lock.json
index d7bc9c9137..cdd676d6cf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
 {
-  "name": "ASWOrdenadorGrande",
+  "name": "wichat_es3b",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
diff --git a/webapp/src/components/chatBox/ChatBox.js b/webapp/src/components/chatBox/ChatBox.js
index 84bc96de8b..90275d378f 100644
--- a/webapp/src/components/chatBox/ChatBox.js
+++ b/webapp/src/components/chatBox/ChatBox.js
@@ -34,8 +34,7 @@ const ChatBox = ({
       const requestData = {
         userQuestion: input,
         question: question,
-        idioma: "es",
-        model: "empathy",
+        idioma: "es"
       };
 
       // Llamar al servicio LLM


Reviews:
user: login: IgnacioLLA
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-05T21:56:31Z
user: login: IgnacioLLA
body: Revisa los comentarios que añadí
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-04-05T21:58:42Z
user: login: IgnacioLLA
body: Con ese cambio, el resto parece estar en orden. Buen trabajo
state: APPROVED
author_association: NONE
submitted_at: 2025-04-06T07:23:35Z

Review comments:
diff_hunk: @@ -5,90 +5,208 @@ const swaggerUi = require("swagger-ui-express");
 const fs = require("fs");
 const YAML = require("yaml");
 const promBundle = require("express-prom-bundle");
+const path = require('path');
 
 const app = express();
-const port = 8006; // Nuevo puerto para el API service
-
-// URL del gateway service
+const port = process.env.PORT || 8006;
path: apiservice/api-service.js
user: login: IgnacioLLA
body: Para qué establecer el puerto en env en vez de dejarlo fijo como los anteriores? Además en ese caso mejor un API_SERVICE_PORT por si debemos añadir algún puerto extra poder diferenciarlos.
created_at: 2025-04-05T21:56:31Z
author_association: NONE
diff_hunk: @@ -4,7 +4,10 @@ const express = require('express');
 require("dotenv").config();
 
 const app = express();
-const port = 8003;
+const port = process.env.PORT || 8003;
path: llmservice/llm-service.js
user: login: IgnacioLLA
body: Es el mismo nombre de puerto que el de arriba, puede generar problemas pues ambos tendrían el mismo puerto
created_at: 2025-04-05T21:57:55Z
author_association: NONE

PR comments:
user: login: UO291047
created_at: 2025-04-06T07:16:13Z
body: El poder especificar puertos en un .env se debía a posibles necesidades de los tests. Tras ver que finalmente podía ejecutar los tests sin hacer uso de este método se me olvidó quitarlo. Ya está eliminado, gracias por el aviso y bien visto.
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-06T07:20:08Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=Arquisoft_wichat_es3b&pullRequest=171) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [1 Security Hotspot](https://sonarcloud.io/project/security_hotspots?id=Arquisoft_wichat_es3b&pullRequest=171&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [12.7% Coverage on New Code](https://sonarcloud.io/component_measures?id=Arquisoft_wichat_es3b&pullRequest=171&metric=new_coverage&view=list) (required ≥ 80%)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [7.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Arquisoft_wichat_es3b&pullRequest=171&metric=new_duplicated_lines_density&view=list) (required ≤ 3%)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [C Reliability Rating on New Code](https://sonarcloud.io/dashboard?id=Arquisoft_wichat_es3b&pullRequest=171) (required ≥ A)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Arquisoft_wichat_es3b&pullRequest=171)

##   
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/light_bulb-16px.png '') Catch issues before they fail your Quality Gate with our IDE extension ![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/sonarlint-16px.png '') [SonarQube for IDE](https://www.sonarsource.com/products/sonarlint/features/connected-mode/?referrer=pull-request)


author_association: NONE