diff --git a/backend/app/clients/nominatim_client.py b/backend/app/clients/nominatim_client.py index eb412cc..9712e52 100644 --- a/backend/app/clients/nominatim_client.py +++ b/backend/app/clients/nominatim_client.py @@ -7,7 +7,7 @@ class NominatimClient: BASE_URL = "https://nominatim.openstreetmap.org/search" - USER_AGENT = "SkillStat/1.0 (proyecto academico UTCJ, contacto: eliaslucinoochoamalaga@gmail.com)" + USER_AGENT = "SkillStat/1.0 (proyecto academico UTCJ, contacto: 195959137+Ochoa-Stack@users.noreply.github.com)" # Valid types that represent a real city/town/village entity. VALID_TYPES = {"city", "town", "village", "municipality"} @@ -31,20 +31,29 @@ class NominatimClient: } @classmethod - def geocode_city(cls, query: str) -> dict | None: - """Geocodes a city name using Nominatim API. - Returns a dict with 'name', 'state', 'lat', 'lon' or None if it fails, timeouts, - or doesn't meet the confidence threshold (must have state, must be a valid city type)""" + def _resolve_query(cls, query: str) -> str: import unicodedata - - # Desambiguación de query normalized_query = query.strip().lower() normalized_query = ''.join(c for c in unicodedata.normalize('NFD', normalized_query) if unicodedata.category(c) != 'Mn') - - # Eliminamos puntos comunes como en "mexico d.f." -> "mexico df" para que coincida con el diccionario normalized_query = normalized_query.replace(".", "") - - actual_query = cls.QUERY_DISAMBIGUATION.get(normalized_query, query) + return cls.QUERY_DISAMBIGUATION.get(normalized_query, query) + + @classmethod + def _is_valid_place_type(cls, result: dict) -> bool: + place_type = result.get("type", "").lower() + place_class = result.get("class", "").lower() + addresstype = result.get("addresstype", "").lower() + return place_type in cls.VALID_TYPES or place_class in cls.VALID_TYPES or addresstype in cls.VALID_TYPES + + @classmethod + def _extract_city_name(cls, result: dict) -> str | None: + address = result.get("address", {}) + return address.get("city") or address.get("town") or address.get("village") or address.get("municipality") or result.get("name") + + @classmethod + def geocode_city(cls, query: str) -> dict | None: + """ Geocodes a city name using Nominatim API. Returns a dict with 'name', 'state', 'lat', 'lon' or None if it fails, timeouts, or doesn't meet the confidence threshold (must have state, must be a valid city type) """ + actual_query = cls._resolve_query(query) # Sleep to respect Nominatim's strict 1 req/sec limit time.sleep(1.1) @@ -70,11 +79,7 @@ def geocode_city(cls, query: str) -> dict | None: result = data[0] - place_type = result.get("type", "").lower() - place_class = result.get("class", "").lower() - addresstype = result.get("addresstype", "").lower() - - if place_type not in cls.VALID_TYPES and place_class not in cls.VALID_TYPES and addresstype not in cls.VALID_TYPES: + if not cls._is_valid_place_type(result): return None address = result.get("address", {}) @@ -83,7 +88,7 @@ def geocode_city(cls, query: str) -> dict | None: if not state: return None - name = address.get("city") or address.get("town") or address.get("village") or address.get("municipality") or result.get("name") + name = cls._extract_city_name(result) if not name: return None diff --git a/backend/app/controllers/admin_bp.py b/backend/app/controllers/admin_bp.py index c624235..1079307 100644 --- a/backend/app/controllers/admin_bp.py +++ b/backend/app/controllers/admin_bp.py @@ -183,7 +183,7 @@ def update_user_role(user_id): new_role = payload["role"] # Si el actor se esta auto-modificando y la operacion lo saca de ADMIN, protegemos contra dejar el sistema sin ningun admin activo. if target.id == actor_id and target.role == "ADMIN" and new_role != "ADMIN": - if UserRepository.count_active_admins_for_update() <= 1: + if UserRepository.is_last_active_admin(actor_id): return error_response( code="LAST_ADMIN_PROTECTED", message="No puedes quitarte el rol de ADMIN: eres el unico administrador activo.", @@ -228,7 +228,7 @@ def update_user_status(user_id): new_status = payload["is_active"] # Misma proteccion de ultimo-admin, aplicada a desactivacion en vez de cambio de rol. if target.id == actor_id and target.role == "ADMIN" and new_status is False: - if UserRepository.count_active_admins_for_update() <= 1: + if UserRepository.is_last_active_admin(actor_id): return error_response( code="LAST_ADMIN_PROTECTED", message="No puedes desactivar tu cuenta: eres el unico administrador activo.", @@ -250,15 +250,11 @@ def update_user_status(user_id): @admin_bp.route("/trigger-pipeline", methods=["POST"]) def trigger_pipeline(): - """Dispara el pipeline diario (snapshots + evaluación de alertas) bajo demanda. - Autenticado exclusivamente via el header X-Pipeline-Trigger-Key, comparado - con PIPELINE_TRIGGER_SECRET usando tiempo constante para evitar timing attacks. - Diseñado para ser invocado por GitHub Actions, sin sesión de usuario.""" + """Dispara el pipeline diario bajo demanda. Autenticado exclusivamente via el header X-Pipeline-Trigger-Key, comparado con PIPELINE_TRIGGER_SECRET usando tiempo constante para evitar timing attacks. Diseñado para ser invocado por GitHub Actions, sin sesión de usuario.""" provided_key = request.headers.get("X-Pipeline-Trigger-Key", "") expected_key = current_app.config.get("PIPELINE_TRIGGER_SECRET", "") - # hmac.compare_digest previene timing attacks: el tiempo de comparación no - # varía según cuántos caracteres coincidan, a diferencia del operador ==. + # hmac.compare_digest previene timing attacks, el tiempo de comparación no varía según cuántos caracteres coincidan, a diferencia del operador ==. if not hmac.compare_digest(provided_key, expected_key): return error_response( code="UNAUTHORIZED", diff --git a/backend/app/controllers/auth_bp.py b/backend/app/controllers/auth_bp.py index 8cf96ee..9997d9b 100644 --- a/backend/app/controllers/auth_bp.py +++ b/backend/app/controllers/auth_bp.py @@ -114,6 +114,7 @@ def get_profile(): @auth_bp.route("/google", methods=["POST"]) +@limiter.limit("10 per 15 minutes") def google_login(): data = request.get_json() or {} credential = data.get("credential") diff --git a/backend/app/repositories/city_repository.py b/backend/app/repositories/city_repository.py index e8041cb..d6f4de3 100644 --- a/backend/app/repositories/city_repository.py +++ b/backend/app/repositories/city_repository.py @@ -7,6 +7,13 @@ class CityRepository(BaseRepository): model = City + @classmethod + def _normalize(cls, text: str) -> str: + return ''.join( + c for c in unicodedata.normalize('NFD', text.strip().lower()) + if unicodedata.category(c) != 'Mn' + ) + @classmethod def get_by_name(cls, name: str): return db.session.execute( @@ -19,14 +26,12 @@ def get_or_create_city(cls, raw_location: str) -> tuple[City | None, bool]: return None, False # lowercase, sin acentos y trim (Normaliza) - normalized = raw_location.strip().lower() - normalized = ''.join(c for c in unicodedata.normalize('NFD', normalized) if unicodedata.category(c) != 'Mn') + normalized = cls._normalize(raw_location) # Búsqueda exhaustiva comparando el nombre normalizado all_cities = db.session.execute(db.select(City)).scalars().all() for city in all_cities: - city_norm = city.name.strip().lower() - city_norm = ''.join(c for c in unicodedata.normalize('NFD', city_norm) if unicodedata.category(c) != 'Mn') + city_norm = cls._normalize(city.name) if city_norm == normalized: return city, False @@ -37,12 +42,10 @@ def get_or_create_city(cls, raw_location: str) -> tuple[City | None, bool]: # Nominatim puede resolver un alias (ej: "Distrito Federal") a un nombre real (ej: "Ciudad de México"). Revisamos si ese nombre real ya existe en BD para evitar IntegrityError secuencial resolved_name = geo_data["name"] - resolved_norm = resolved_name.strip().lower() - resolved_norm = ''.join(c for c in unicodedata.normalize('NFD', resolved_norm) if unicodedata.category(c) != 'Mn') + resolved_norm = cls._normalize(resolved_name) for city in all_cities: - city_norm = city.name.strip().lower() - city_norm = ''.join(c for c in unicodedata.normalize('NFD', city_norm) if unicodedata.category(c) != 'Mn') + city_norm = cls._normalize(city.name) if city_norm == resolved_norm: return city, False diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index 1a42d73..0cf874a 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -9,22 +9,26 @@ class UserRepository: # Encapsula el acceso a datos para la entidad User. Aísla las consultas SQLAlchemy de la lógica de negocio. - + @classmethod - def create(cls, user_data: dict) -> User: - user = User(**user_data) - db.session.add(user) + def _commit_or_rollback(cls, user: User, action: str) -> User: try: db.session.commit() return user except IntegrityError as e: db.session.rollback() - logger.warning("Violacion de integridad al crear User: %s", str(e)) - raise ConflictError("No se pudo crear el usuario: conflicto de integridad de datos.") + logger.warning(f"Violacion de integridad al {action} User: {e}") + raise ConflictError(f"No se pudo {action} el usuario: conflicto de integridad de datos.") except Exception as e: db.session.rollback() - logger.error("Fallo inesperado al crear User: %s", str(e)) - raise AppError("Error interno al crear el usuario.", code="DATABASE_ERROR", status_code=500) + logger.error(f"Fallo inesperado al {action} User: {e}") + raise AppError(f"Error interno al {action} el usuario.", code="DATABASE_ERROR", status_code=500) + + @classmethod + def create(cls, user_data: dict) -> User: + user = User(**user_data) + db.session.add(user) + return cls._commit_or_rollback(user, "crear") @classmethod def get_by_id(cls, user_id: int) -> User: @@ -55,34 +59,17 @@ def get_paginated(cls, page: int = 1, per_page: int = 20): def save(cls, user: User) -> User: # Persiste cambios en una entidad ya existente, como el reseteo de password_hash; no crea un nuevo registro, solo hace commit. db.session.add(user) - try: - db.session.commit() - return user - except IntegrityError as e: - db.session.rollback() - logger.warning("Violacion de integridad al guardar User: %s", str(e)) - raise ConflictError("No se pudo guardar el usuario: conflicto de integridad de datos.") - except Exception as e: - db.session.rollback() - logger.error("Fallo inesperado al guardar User: %s", str(e)) - raise AppError("Error interno al guardar el usuario.", code="DATABASE_ERROR", status_code=500) - - @classmethod - def count_active_admins(cls) -> int: - # Cuenta administradores activos para proteger contra que una operación deje al sistema sin ningún ADMIN capaz de operar el panel. - return db.session.execute( - db.select(db.func.count()).select_from(User).filter_by( - role="ADMIN", is_active=True - ) - ).scalar_one() + return cls._commit_or_rollback(user, "guardar") @classmethod def count_active_admins_for_update(cls) -> int: - # Version con lock de fila explicito (SELECT ... FOR UPDATE) para proteger contra condiciones de carrera reales: dos requests concurrentes intentando degradar/desactivar a los dos ultimos administradores activos al mismo tiempo. El lock se retiene hasta el commit() de la transaccion actual (el que ya ocurre dentro de save()), forzando que la segunda request espere a que la primera termine antes de leer un conteo actualizado. - # Nota de implementacion: with_for_update() no es compatible directamente con func.count() como subquery en SQLAlchemy 2.x, por lo que se aplica FOR UPDATE sobre la query de filas y se cuenta el resultado en Python (equivalente semanticamente). rows = db.session.execute( db.select(User.id).filter_by( role="ADMIN", is_active=True ).with_for_update() ).all() return len(rows) + + @classmethod + def is_last_active_admin(cls, user_id: int) -> bool: + return cls.count_active_admins_for_update() <= 1 diff --git a/backend/app/services/alerts_service.py b/backend/app/services/alerts_service.py index 01e30a4..1f2f3f7 100644 --- a/backend/app/services/alerts_service.py +++ b/backend/app/services/alerts_service.py @@ -1,3 +1,4 @@ +import logging from app.repositories.alert_repository import AlertRepository from app.repositories.trend_snapshot_repository import TrendSnapshotRepository from app.repositories.user_repository import UserRepository @@ -5,7 +6,29 @@ from app.services.email_service import send_alert_email from app.utils.errors import AppError +logger = logging.getLogger(__name__) + class AlertsService: + @classmethod + def _build_notification_content(cls, alert, skill, latest_trend): + if alert.alert_type == "ABSOLUTE": + subject = f"Alerta SkillStat: {skill.name} ha superado tu umbral" + html_content = f""" +

Alerta de Mercado Laboral

+

Hola, tu alerta configurada para {skill.name} ha sido activada.

+

El mercado actual registra {latest_trend.demand_count} vacantes activas, superando tu umbral de {alert.threshold_value}.

+

Ver en el Panorama

+ """ + else: + subject = f"Alerta SkillStat: {skill.name} está en tendencia de crecimiento" + html_content = f""" +

Alerta de Mercado Laboral

+

Hola, tu alerta de tendencia configurada para {skill.name} ha sido activada.

+

Esta habilidad ha crecido {latest_trend.growth_rate}% en la última semana, superando tu umbral de {alert.threshold_percentage}%.

+

Ver en el Panorama

+ """ + return subject, html_content + @classmethod def evaluate_and_notify(cls) -> int: active_alerts = AlertRepository.get_active() @@ -21,7 +44,7 @@ def evaluate_and_notify(cls) -> int: if latest_trend.demand_count is not None and latest_trend.demand_count >= alert.threshold_value: triggered = True elif alert.alert_type == "TREND": - # Sin historial de 7 dias, growth_rate es None -- no evaluamos, no notificamos. Ausencia de dato no es lo mismo que "no se cumplio". + # Sin historial de 7 dias, growth_rate es None, no evaluamos, no notificamos. Ausencia de dato no es lo mismo que "no se cumplio". if latest_trend.growth_rate is not None and latest_trend.growth_rate >= alert.threshold_percentage: triggered = True if not triggered: @@ -30,25 +53,16 @@ def evaluate_and_notify(cls) -> int: skill = SkillRepository.get_by_id(alert.skill_id) if not user or not skill: continue - if alert.alert_type == "ABSOLUTE": - subject = f"Alerta SkillStat: {skill.name} ha superado tu umbral" - html_content = f""" -

Alerta de Mercado Laboral

-

Hola, tu alerta configurada para {skill.name} ha sido activada.

-

El mercado actual registra {latest_trend.demand_count} vacantes activas, superando tu umbral de {alert.threshold_value}.

-

Ver en el Panorama

- """ - else: - subject = f"Alerta SkillStat: {skill.name} está en tendencia de crecimiento" - html_content = f""" -

Alerta de Mercado Laboral

-

Hola, tu alerta de tendencia configurada para {skill.name} ha sido activada.

-

Esta habilidad ha crecido {latest_trend.growth_rate}% en la última semana, superando tu umbral de {alert.threshold_percentage}%.

-

Ver en el Panorama

- """ + + subject, html_content = cls._build_notification_content(alert, skill, latest_trend) + try: send_alert_email(user.email, subject, html_content) notifications_sent += 1 - except AppError: + except AppError as e: + logger.warning( + "Fallo al enviar correo de alerta: alert_id=%s user_id=%s. Error: %s", + alert.id, alert.user_id, str(e) + ) continue return notifications_sent diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 4fc5164..533d92e 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -1,4 +1,5 @@ import os +from urllib.parse import urlparse from flask_migrate import upgrade import subprocess from datetime import datetime @@ -14,9 +15,23 @@ class BackupService: # Encapsula la ejecución de comandos del sistema operativo (pg_dump). Requisito obligatorio de infraestructura y recuperación. + @classmethod + def _get_connection_params(cls, db_url: str) -> dict: + # Usamos urlparse para manejar correctamente passwords con caracteres especiales que el split manual no puede resolver. + parsed = urlparse(db_url) + password = parsed.password or "" + env = os.environ.copy() + env["PGPASSWORD"] = password + return { + "host": parsed.hostname, + "port": str(parsed.port or 5432), + "user": parsed.username, + "db_name": parsed.path.lstrip("/"), + "env": env, + } + @classmethod def execute_database_backup(cls, requested_by: int = None) -> dict: - from urllib.parse import urlparse db_url = current_app.config.get("SQLALCHEMY_DATABASE_URI", "") @@ -45,28 +60,19 @@ def execute_database_backup(cls, requested_by: int = None) -> dict: }) try: - # Usamos urlparse para manejar correctamente passwords con caracteres especiales que el split manual no puede resolver. - parsed = urlparse(db_url) - user = parsed.username - password = parsed.password or "" - host = parsed.hostname - port = str(parsed.port or 5432) - db_name = parsed.path.lstrip("/") - - env = os.environ.copy() - env["PGPASSWORD"] = password + conn = cls._get_connection_params(db_url) command = [ "pg_dump", - "-h", host, - "-p", port, - "-U", user, + "-h", conn["host"], + "-p", conn["port"], + "-U", conn["user"], "-F", "c", "-f", filepath, - db_name, + conn["db_name"], ] - subprocess.run(command, env=env, capture_output=True, text=True, check=True) + subprocess.run(command, env=conn["env"], capture_output=True, text=True, check=True) file_size = os.path.getsize(filepath) RemoteStorageService.upload_backup(filepath, filename) @@ -104,7 +110,6 @@ def execute_database_backup(cls, requested_by: int = None) -> dict: @classmethod def restore_database_backup(cls, backup_id: int, requested_by: int) -> dict: - from urllib.parse import urlparse backup = BackupRepository.get_by_id(backup_id) if not backup: raise AppError("El respaldo solicitado no existe.", code="NOT_FOUND", status_code=404) @@ -150,22 +155,15 @@ def restore_database_backup(cls, backup_id: int, requested_by: int) -> dict: ) db_url = current_app.config.get("SQLALCHEMY_DATABASE_URI", "") - parsed = urlparse(db_url) - user = parsed.username - password = parsed.password or "" - host = parsed.hostname - port = str(parsed.port or 5432) - db_name = parsed.path.lstrip("/") - env = os.environ.copy() - env["PGPASSWORD"] = password + conn = cls._get_connection_params(db_url) command = [ "pg_restore", "--clean", "--if-exists", - "-h", host, - "-p", port, - "-U", user, - "-d", db_name, + "-h", conn["host"], + "-p", conn["port"], + "-U", conn["user"], + "-d", conn["db_name"], backup_filepath, ] @@ -173,7 +171,7 @@ def restore_database_backup(cls, backup_id: int, requested_by: int) -> dict: db.session.remove() try: - subprocess.run(command, env=env, capture_output=True, text=True, check=True) + subprocess.run(command, env=conn["env"], capture_output=True, text=True, check=True) except subprocess.CalledProcessError as e: raise AppError( f"Fallo en ejecucion de pg_restore: {e.stderr}", diff --git a/backend/app/services/ingestion_service.py b/backend/app/services/ingestion_service.py index e928d8e..8963bae 100644 --- a/backend/app/services/ingestion_service.py +++ b/backend/app/services/ingestion_service.py @@ -1,4 +1,5 @@ import logging +import re import hashlib import click from flask import current_app @@ -43,6 +44,11 @@ def run_ingestion(cls, country: str = "mx", what: str = "software developer", pa return stats + @classmethod + def _compute_is_remote(cls, title: str, description: str) -> bool: + search_text = f"{title} {description}".lower() + return bool(re.search(r'\bremote\b', search_text) or re.search(r'\bremoto\b', search_text)) + @classmethod def _process_job(cls, item: dict, known_skills: dict, stats: dict, verbose: bool = False) -> None: description = item.get("description", "") @@ -62,8 +68,8 @@ def _process_job(cls, item: dict, known_skills: dict, stats: dict, verbose: bool company = item.get("company", {}).get("display_name", "Confidencial") url = item.get("redirect_url", "") - # Guardamos sin location estricta hasta integrar Nominatim, determinando la bandera remote de forma aislada. - is_remote = "remote" in str(item).lower() or "remoto" in str(item).lower() + # Acotamos la búsqueda de remote/remoto a title y description con límites de palabra (\b) para evitar falsos positivos por nombres de empresa (ej. "RemoteWorks Solutions") o coincidencias parciales. + is_remote = cls._compute_is_remote(title, description) salary_min = item.get("salary_min") salary_max = item.get("salary_max") @@ -120,8 +126,7 @@ def _process_job(cls, item: dict, known_skills: dict, stats: dict, verbose: bool @classmethod def _resolve_city(cls, raw_location: str, stats: dict, verbose: bool = False): - """Resuelve la ubicación cruda de Adzuna a una fila de la tabla cities. - Devuelve (city_id, label_para_log). Usa "México Nacional" como fallback cuando la geocodificación falla o la ubicación está vacía, para garantizar que city_id nunca quede nulo""" + """Resuelve la ubicación cruda de Adzuna a una fila de la tabla cities. Devuelve (city_id, label_para_log). Usa "México Nacional" como fallback cuando la geocodificación falla o la ubicación está vacía, para garantizar que city_id nunca quede nulo""" city, created = CityRepository.get_or_create_city(raw_location) if raw_location else (None, False) if city: diff --git a/backend/scripts/backfill_remote_flag.py b/backend/scripts/backfill_remote_flag.py new file mode 100644 index 0000000..4d5cf23 --- /dev/null +++ b/backend/scripts/backfill_remote_flag.py @@ -0,0 +1,74 @@ +import os +import click +from dotenv import load_dotenv + +# Asegurar que estamos en el directorio base correcto para cargar .env si es necesario +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +env_path = os.path.join(BASE_DIR, ".env") +if os.path.exists(env_path): + load_dotenv(env_path) + +from app import create_app +from app.extensions import db +from app.models.job import Job +from app.services.ingestion_service import IngestionService + +@click.command() +@click.option('--execute', is_flag=True, help="Ejecutar los cambios en la base de datos (por defecto es dry-run)") +def backfill_remote_flag(execute): + """ + Recalcula el campo 'remote' de todos los jobs existentes usando la nueva lógica de IngestionService. Por defecto corre en modo dry-run (solo reporta). Usa --execute para aplicar los cambios. """ + app = create_app(os.getenv("FLASK_ENV", "development")) + + with app.app_context(): + print(f"Iniciando backfill de flag 'remote' (Modo: {'EXECUTE' if execute else 'DRY-RUN'})") + print("-" * 60) + + jobs = db.session.execute(db.select(Job)).scalars().all() + + total_jobs = len(jobs) + changed_to_true = 0 + changed_to_false = 0 + unchanged = 0 + errors = 0 + + for job in jobs: + try: + # El título o descripción pueden ser None en la BD? Según los modelos y el schema, title y description suelen ser strings, pero por precaución: + title = job.title or "" + description = job.description or "" + + new_remote = IngestionService._compute_is_remote(title, description) + + if new_remote != job.remote: + if new_remote is True: + changed_to_true += 1 + else: + changed_to_false += 1 + + if execute: + job.remote = new_remote + db.session.commit() + print(f"Actualizado Job ID {job.id}: remote -> {new_remote}") + else: + unchanged += 1 + except Exception as e: + errors += 1 + print(f"Error procesando Job ID {job.id}: {e}") + if execute: + db.session.rollback() + + print("-" * 60) + print("Resumen del backfill:") + print(f" Total de jobs analizados : {total_jobs}") + print(f" Cambiaron a True : {changed_to_true}") + print(f" Cambiaron a False : {changed_to_false}") + print(f" Sin cambios : {unchanged}") + print(f" Errores : {errors}") + + if not execute: + print("\nNOTA: Ejecución en modo DRY-RUN. No se guardaron cambios en la base de datos.") + print(" Para aplicar los cambios, ejecuta el script con el flag --execute.") + +if __name__ == '__main__': + backfill_remote_flag() diff --git a/backend/tests/integration/test_city_repository.py b/backend/tests/integration/test_city_repository.py new file mode 100644 index 0000000..4ee38ab --- /dev/null +++ b/backend/tests/integration/test_city_repository.py @@ -0,0 +1,100 @@ +import pytest +from unittest.mock import MagicMock + +from app.models.city import City +from app.repositories.city_repository import CityRepository + +def _make_city(db_session, name="Ciudad Test", state="Estado Test", lat=19.4326, lon=-99.1332): + city = City(name=name, state=state, lat=lat, lon=lon, country="MX") + db_session.add(city) + db_session.commit() + return city + +def test_get_or_create_city_returns_existing_exact_match(app, db_session): + """ get_or_create_city retorna una ciudad existente sin crear una nueva cuando el nombre coincide exactamente. """ + _make_city(db_session, name="Guadalajara") + + with app.app_context(): + city, created = CityRepository.get_or_create_city("Guadalajara") + assert not created + assert city is not None + assert city.name == "Guadalajara" + +def test_get_or_create_city_returns_existing_after_normalization(app, db_session): + """ get_or_create_city retorna una ciudad existente cuando el nombre coincide tras normalización (acentos, mayúsculas/minúsculas). """ + _make_city(db_session, name="Querétaro") + + with app.app_context(): + city, created = CityRepository.get_or_create_city(" queretaro ") + assert not created + assert city is not None + assert city.name == "Querétaro" + +def test_get_or_create_city_creates_new_when_valid_data(app, db_session, monkeypatch): + """ get_or_create_city crea una ciudad nueva cuando no existe y Nominatim (mockeado) retorna datos válidos, devolviendo (city, True). """ + mock_geocode = MagicMock(return_value={ + "name": "Monterrey", + "state": "Nuevo León", + "lat": 25.6866, + "lon": -100.3161 + }) + monkeypatch.setattr("app.repositories.city_repository.NominatimClient.geocode_city", mock_geocode) + + with app.app_context(): + city, created = CityRepository.get_or_create_city("Monterrey") + assert created + assert city is not None + assert city.name == "Monterrey" + assert city.state == "Nuevo León" + mock_geocode.assert_called_once_with("Monterrey") + +def test_get_or_create_city_returns_none_when_not_found(app, db_session, monkeypatch): + """ get_or_create_city retorna (None, False) cuando Nominatim (mockeado) no encuentra resultado. """ + mock_geocode = MagicMock(return_value=None) + monkeypatch.setattr("app.repositories.city_repository.NominatimClient.geocode_city", mock_geocode) + + with app.app_context(): + city, created = CityRepository.get_or_create_city("Ciudad Inexistente 123") + assert not created + assert city is None + mock_geocode.assert_called_once_with("Ciudad Inexistente 123") + +def test_get_or_create_city_detects_resolved_name_already_exists(app, db_session, monkeypatch): + """ get_or_create_city detecta que el nombre resuelto por Nominatim ya existe en base de datos aunque el nombre original consultado no coincidiera (caso de alias, ej. "Distrito Federal" resolviendo a "Ciudad de México" ya existente) y retorna esa ciudad sin duplicar. """ + _make_city(db_session, name="Ciudad de México") + + mock_geocode = MagicMock(return_value={ + "name": "Ciudad de México", + "state": "Ciudad de México", + "lat": 19.4326, + "lon": -99.1332 + }) + monkeypatch.setattr("app.repositories.city_repository.NominatimClient.geocode_city", mock_geocode) + + with app.app_context(): + city, created = CityRepository.get_or_create_city("Distrito Federal") + assert not created + assert city is not None + assert city.name == "Ciudad de México" + mock_geocode.assert_called_once_with("Distrito Federal") + +def test_get_or_create_city_returns_none_for_empty_raw_location(app, db_session): + """ get_or_create_city retorna (None, False) cuando raw_location está vacío o es None. """ + with app.app_context(): + city1, created1 = CityRepository.get_or_create_city("") + city2, created2 = CityRepository.get_or_create_city(None) + assert city1 is None + assert not created1 + assert city2 is None + assert not created2 + +def test_get_by_name_returns_city_if_exists(app, db_session): + """ get_by_name retorna la ciudad correcta cuando existe, y None cuando no. """ + _make_city(db_session, name="Cancún") + + with app.app_context(): + city_found = CityRepository.get_by_name("Cancún") + city_missing = CityRepository.get_by_name("Mérida") + assert city_found is not None + assert city_found.name == "Cancún" + assert city_missing is None diff --git a/backend/tests/integration/test_ingestion_service.py b/backend/tests/integration/test_ingestion_service.py new file mode 100644 index 0000000..20058c4 --- /dev/null +++ b/backend/tests/integration/test_ingestion_service.py @@ -0,0 +1,210 @@ +import pytest +from unittest.mock import MagicMock + +from app.services.ingestion_service import IngestionService, MEXICO_NACIONAL_CITY_ID +from app.utils.errors import AppError + +def test_run_ingestion_stops_on_apperror_and_preserves_stats(monkeypatch): + """ run_ingestion detiene la paginación cuando AdzunaClient.get_jobs lanza AppError, preservando las estadísticas acumuladas hasta ese punto. """ + mock_get_jobs = MagicMock() + # Primera página devuelve 2 jobs. Segunda página lanza AppError. + mock_get_jobs.side_effect = [ + {"results": [{"description": "Job 1"}, {"description": "Job 2"}]}, + AppError("API Error") + ] + monkeypatch.setattr("app.services.ingestion_service.AdzunaClient.get_jobs", mock_get_jobs) + + mock_get_all_skills = MagicMock(return_value=[]) + monkeypatch.setattr("app.services.ingestion_service.SkillRepository.get_all", mock_get_all_skills) + + mock_process_job = MagicMock() + monkeypatch.setattr("app.services.ingestion_service.IngestionService._process_job", mock_process_job) + + stats = IngestionService.run_ingestion(pages=3) + + # Se intentaron 2 páginas antes de detenerse + assert mock_get_jobs.call_count == 2 + # El loop interno procesó los 2 jobs de la página 1 antes de detenerse + assert mock_process_job.call_count == 2 + assert stats["fetched"] == 2 + +def test_process_job_increments_duplicates_when_hash_exists(monkeypatch): + """ _process_job incrementa stats['duplicates'] y no llama a JobRepository.create cuando JobRepository.get_by_hash ya encuentra un hash existente. """ + mock_get_by_hash = MagicMock(return_value={"id": 1}) # Any truthy value implies it exists + monkeypatch.setattr("app.services.ingestion_service.JobRepository.get_by_hash", mock_get_by_hash) + + mock_create_job = MagicMock() + monkeypatch.setattr("app.services.ingestion_service.JobRepository.create", mock_create_job) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + item = {"description": "This is a duplicate job"} + IngestionService._process_job(item, {}, stats) + + assert stats["duplicates"] == 1 + mock_get_by_hash.assert_called_once() + mock_create_job.assert_not_called() + +def test_process_job_increments_errors_on_empty_description(monkeypatch): + """ _process_job incrementa stats['errors'] cuando la descripción del job está vacía, sin llegar a calcular el hash ni tocar el repositorio. """ + mock_get_by_hash = MagicMock() + monkeypatch.setattr("app.services.ingestion_service.JobRepository.get_by_hash", mock_get_by_hash) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + # Missing description + IngestionService._process_job({}, {}, stats) + + assert stats["errors"] == 1 + mock_get_by_hash.assert_not_called() + +def test_process_job_extracts_last_area_as_raw_location(monkeypatch): + """ _process_job extrae correctamente el último elemento de location.area como raw_location antes de resolver la ciudad. """ + monkeypatch.setattr("app.services.ingestion_service.JobRepository.get_by_hash", MagicMock(return_value=None)) + + mock_job = MagicMock() + mock_job.id = 99 + monkeypatch.setattr("app.services.ingestion_service.JobRepository.create", MagicMock(return_value=mock_job)) + monkeypatch.setattr("app.services.ingestion_service.SkillsExtractionService.extract_skills", MagicMock(return_value=[])) + + mock_resolve_city = MagicMock(return_value=(1, "Mock City")) + monkeypatch.setattr("app.services.ingestion_service.IngestionService._resolve_city", mock_resolve_city) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + item = { + "description": "Job desc", + "location": { + "area": ["Country", "State", "CitySpecific"] + } + } + + IngestionService._process_job(item, {}, stats) + + mock_resolve_city.assert_called_once_with("CitySpecific", stats, verbose=False) + +def test_resolve_city_returns_fallback(monkeypatch): + """ _resolve_city retorna MEXICO_NACIONAL_CITY_ID y registra stats['fallback'] cuando CityRepository.get_or_create_city retorna (None, False). """ + monkeypatch.setattr("app.services.ingestion_service.CityRepository.get_or_create_city", MagicMock(return_value=(None, False))) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + city_id, label = IngestionService._resolve_city("UnknownPlace", stats) + + assert city_id == MEXICO_NACIONAL_CITY_ID + assert stats["fallback"] == 1 + assert stats["cities_created"] == 0 + +def test_resolve_city_returns_id_and_increments_created_when_new(monkeypatch): + """ _resolve_city retorna el id real y registra stats['cities_created'] cuando CityRepository.get_or_create_city retorna una ciudad nueva (created=True). """ + mock_city = MagicMock() + mock_city.id = 42 + mock_city.name = "TestCity" + mock_city.state = "TestState" + + monkeypatch.setattr("app.services.ingestion_service.CityRepository.get_or_create_city", MagicMock(return_value=(mock_city, True))) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + city_id, label = IngestionService._resolve_city("TestCity", stats) + + assert city_id == 42 + assert stats["cities_created"] == 1 + assert stats["fallback"] == 0 + +def test_get_or_create_skill_reuses_id_case_insensitive(monkeypatch): + """ _get_or_create_skill reutiliza el id ya presente en known_skills sin llamar a SkillRepository.create cuando el skill ya existe en el diccionario en memoria (verificar case-insensitive). """ + mock_create_skill = MagicMock() + monkeypatch.setattr("app.services.ingestion_service.SkillRepository.create", mock_create_skill) + + known_skills = {"python": 100} + + # Testing case insensitivity: the dictionary key is "python", we pass "PyThOn" + skill_id = IngestionService._get_or_create_skill("PyThOn", known_skills) + + assert skill_id == 100 + mock_create_skill.assert_not_called() + +def test_is_remote_ignores_company_name_containing_remote(monkeypatch): + """ is_remote solo examina title y description. Un job cuya empresa se llama "RemoteWorks Solutions" pero cuyo título y descripción son presenciales NO debe marcarse como remoto. Verifica el comportamiento correcto tras la corrección del bug. """ + monkeypatch.setattr("app.services.ingestion_service.JobRepository.get_by_hash", MagicMock(return_value=None)) + + mock_job = MagicMock() + mock_job.id = 99 + mock_create_job = MagicMock(return_value=mock_job) + monkeypatch.setattr("app.services.ingestion_service.JobRepository.create", mock_create_job) + monkeypatch.setattr("app.services.ingestion_service.SkillsExtractionService.extract_skills", MagicMock(return_value=[])) + monkeypatch.setattr("app.services.ingestion_service.CityRepository.get_or_create_city", MagicMock(return_value=(None, False))) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + # Empresa con "Remote" en el nombre, pero título y descripción completamente presenciales. + item = { + "title": "Backend Developer (On-site)", + "description": "We need an on-site backend developer to work in our office in Monterrey.", + "company": { + "display_name": "RemoteWorks Solutions" + } + } + + IngestionService._process_job(item, {}, stats) + + job_data_passed = mock_create_job.call_args[0][0] + + # El nombre de la empresa NO debe influir en la detección de modalidad. + assert job_data_passed["remote"] is False + + +def test_is_remote_detects_genuine_remote_in_description(monkeypatch): + """ is_remote detecta correctamente 'remoto' como palabra completa dentro de la descripción. Caso positivo genuino: la modalidad remota sí está mencionada en el texto de la vacante. """ + monkeypatch.setattr("app.services.ingestion_service.JobRepository.get_by_hash", MagicMock(return_value=None)) + + mock_job = MagicMock() + mock_job.id = 100 + mock_create_job = MagicMock(return_value=mock_job) + monkeypatch.setattr("app.services.ingestion_service.JobRepository.create", mock_create_job) + monkeypatch.setattr("app.services.ingestion_service.SkillsExtractionService.extract_skills", MagicMock(return_value=[])) + monkeypatch.setattr("app.services.ingestion_service.CityRepository.get_or_create_city", MagicMock(return_value=(None, False))) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + item = { + "title": "Desarrollador Backend", + "description": "Puesto 100% remoto, no requiere presencia en oficina. Trabajo desde casa.", + "company": {"display_name": "TechCorp"} + } + + IngestionService._process_job(item, {}, stats) + + job_data_passed = mock_create_job.call_args[0][0] + + # La palabra 'remoto' en la descripción debe marcar el job como remoto. + assert job_data_passed["remote"] is True + + +def test_is_remote_does_not_match_partial_word_containing_remoto(monkeypatch): + """ is_remote usa \\b (límite de palabra), por lo que una cadena que contenga 'remoto' como subcadena de otra palabra (ej. 'remotorizado') NO debe disparar un falso positivo. """ + monkeypatch.setattr("app.services.ingestion_service.JobRepository.get_by_hash", MagicMock(return_value=None)) + + mock_job = MagicMock() + mock_job.id = 101 + mock_create_job = MagicMock(return_value=mock_job) + monkeypatch.setattr("app.services.ingestion_service.JobRepository.create", mock_create_job) + monkeypatch.setattr("app.services.ingestion_service.SkillsExtractionService.extract_skills", MagicMock(return_value=[])) + monkeypatch.setattr("app.services.ingestion_service.CityRepository.get_or_create_city", MagicMock(return_value=(None, False))) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + # 'remotorizado' contiene la subcadena 'remoto' pero no es la palabra 'remoto'. + item = { + "title": "Tecnico Electrico", + "description": "Se requiere conocimiento en sistemas remotorizado y control de motores.", + "company": {"display_name": "ElectroCorp"} + } + + IngestionService._process_job(item, {}, stats) + + job_data_passed = mock_create_job.call_args[0][0] + + # 'remotorizado' no debe confundirse con la palabra 'remoto'. + assert job_data_passed["remote"] is False diff --git a/backend/tests/integration/test_nominatim_client.py b/backend/tests/integration/test_nominatim_client.py new file mode 100644 index 0000000..a5ee230 --- /dev/null +++ b/backend/tests/integration/test_nominatim_client.py @@ -0,0 +1,184 @@ +import pytest +from unittest.mock import MagicMock +from requests.exceptions import RequestException + +from app.clients.nominatim_client import NominatimClient + +@pytest.fixture +def mock_sleep(monkeypatch): + mock = MagicMock() + monkeypatch.setattr("app.clients.nominatim_client.time.sleep", mock) + return mock + +def test_geocode_city_returns_valid_data(monkeypatch, mock_sleep): + """ geocode_city retorna un dict con name/state/lat/lon cuando la respuesta de Nominatim es válida y tiene un tipo de lugar en VALID_TYPES. """ + mock_response = MagicMock() + mock_response.json.return_value = [{ + "type": "city", + "lat": "19.4326", + "lon": "-99.1332", + "name": "Ciudad de México", + "address": { + "city": "Ciudad de México", + "state": "Ciudad de México" + } + }] + mock_get = MagicMock(return_value=mock_response) + monkeypatch.setattr("app.clients.nominatim_client.requests.get", mock_get) + + result = NominatimClient.geocode_city("Ciudad de México") + + assert result == { + "name": "Ciudad de México", + "state": "Ciudad de México", + "lat": 19.4326, + "lon": -99.1332 + } + mock_sleep.assert_called_once_with(1.1) + +def test_geocode_city_returns_none_when_empty_response(monkeypatch, mock_sleep): + """ geocode_city retorna None cuando la respuesta de Nominatim viene vacía (lista vacía). """ + mock_response = MagicMock() + mock_response.json.return_value = [] + monkeypatch.setattr("app.clients.nominatim_client.requests.get", MagicMock(return_value=mock_response)) + + result = NominatimClient.geocode_city("Ciudad Fantasma") + assert result is None + +def test_geocode_city_returns_none_when_missing_state(monkeypatch, mock_sleep): + """ geocode_city retorna None cuando el resultado no tiene un state en address (caso de dato insuficiente). """ + mock_response = MagicMock() + mock_response.json.return_value = [{ + "type": "city", + "lat": "19.4326", + "lon": "-99.1332", + "name": "Ciudad de México", + "address": { + "city": "Ciudad de México" + # No state + } + }] + monkeypatch.setattr("app.clients.nominatim_client.requests.get", MagicMock(return_value=mock_response)) + + result = NominatimClient.geocode_city("Ciudad sin estado") + assert result is None + +def test_geocode_city_returns_none_when_invalid_type(monkeypatch, mock_sleep): + """ geocode_city retorna None cuando el tipo de lugar (type/class/addresstype) no está en VALID_TYPES. """ + mock_response = MagicMock() + mock_response.json.return_value = [{ + "type": "residential", # Invalid type + "lat": "19.4326", + "lon": "-99.1332", + "name": "Colonia X", + "address": { + "city": "Ciudad", + "state": "Estado" + } + }] + monkeypatch.setattr("app.clients.nominatim_client.requests.get", MagicMock(return_value=mock_response)) + + result = NominatimClient.geocode_city("Colonia") + assert result is None + +def test_geocode_city_applies_disambiguation(monkeypatch, mock_sleep): + """ geocode_city aplica la desambiguación del diccionario QUERY_DISAMBIGUATION correctamente. """ + mock_response = MagicMock() + mock_response.json.return_value = [{ + "type": "city", + "lat": "19.4326", + "lon": "-99.1332", + "name": "Ciudad de México", + "address": { + "city": "Ciudad de México", + "state": "Ciudad de México" + } + }] + mock_get = MagicMock(return_value=mock_response) + monkeypatch.setattr("app.clients.nominatim_client.requests.get", mock_get) + + result = NominatimClient.geocode_city("cdmx") + + assert result is not None + called_params = mock_get.call_args[1]["params"] + assert called_params["q"] == "Ciudad de Mexico" + +def test_geocode_city_normalizes_and_applies_disambiguation(monkeypatch, mock_sleep): + """ geocode_city normaliza el query eliminando acentos y puntos antes de buscar en el diccionario de desambiguación. """ + mock_response = MagicMock() + mock_response.json.return_value = [{ + "type": "city", + "lat": "19.4326", + "lon": "-99.1332", + "name": "Ciudad de México", + "address": { + "city": "Ciudad de México", + "state": "Ciudad de México" + } + }] + mock_get = MagicMock(return_value=mock_response) + monkeypatch.setattr("app.clients.nominatim_client.requests.get", mock_get) + + result = NominatimClient.geocode_city(" méxico d.f. ") + + assert result is not None + called_params = mock_get.call_args[1]["params"] + assert called_params["q"] == "Ciudad de Mexico" + +def test_geocode_city_returns_none_on_request_exception(monkeypatch, mock_sleep): + """ geocode_city retorna None cuando requests.get lanza una excepción de tipo RequestException. """ + mock_get = MagicMock(side_effect=RequestException("Timeout!")) + monkeypatch.setattr("app.clients.nominatim_client.requests.get", mock_get) + + result = NominatimClient.geocode_city("Cualquier cosa") + assert result is None + +def test_geocode_city_returns_none_on_value_error(monkeypatch, mock_sleep): + """ geocode_city retorna None cuando la respuesta no es JSON válido (ValueError al parsear). """ + mock_response = MagicMock() + mock_response.json.side_effect = ValueError("Invalid JSON") + monkeypatch.setattr("app.clients.nominatim_client.requests.get", MagicMock(return_value=mock_response)) + + result = NominatimClient.geocode_city("Cualquier cosa") + assert result is None + +def test_geocode_city_uses_name_fallback_priority(monkeypatch, mock_sleep): + """ Verifica que geocode_city usa como name el primer campo disponible en el orden: city -> town -> village -> municipality -> name """ + mock_response = MagicMock() + mock_response.json.return_value = [{ + "type": "city", + "lat": "19.4326", + "lon": "-99.1332", + "name": "Result Name", + "address": { + "state": "Estado", + "municipality": "Muni", + "village": "Aldea", + "town": "Pueblo", + "city": "Ciudad Principal" + } + }] + monkeypatch.setattr("app.clients.nominatim_client.requests.get", MagicMock(return_value=mock_response)) + + result = NominatimClient.geocode_city("Test Priority") + assert result["name"] == "Ciudad Principal" + + # Caso sin city, debe usar town + mock_response.json.return_value[0]["address"].pop("city") + result = NominatimClient.geocode_city("Test Priority") + assert result["name"] == "Pueblo" + + # Caso sin town, debe usar village + mock_response.json.return_value[0]["address"].pop("town") + result = NominatimClient.geocode_city("Test Priority") + assert result["name"] == "Aldea" + + # Caso sin village, debe usar municipality + mock_response.json.return_value[0]["address"].pop("village") + result = NominatimClient.geocode_city("Test Priority") + assert result["name"] == "Muni" + + # Caso sin municipality, debe usar name de result + mock_response.json.return_value[0]["address"].pop("municipality") + result = NominatimClient.geocode_city("Test Priority") + assert result["name"] == "Result Name"