diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..bbfc2fc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,84 @@ +name: Tests + +on: + push: + branches: [develop] + pull_request: + branches: [develop] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: usuario + POSTGRES_PASSWORD: password + POSTGRES_DB: skillstat_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + working-directory: backend + run: | + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Run database migrations + working-directory: backend + run: flask db upgrade + env: + FLASK_ENV: "testing" + SECRET_KEY: "test-secret-key" + TEST_DATABASE_URL: "postgresql://usuario:password@localhost:5432/skillstat_test" + JWT_SECRET_KEY: "test-jwt-secret" + GOOGLE_CLIENT_ID: "test-google-client-id" + FRONTEND_BASE_URL: "http://localhost:5500/frontend" + PIPELINE_TRIGGER_SECRET: "test-pipeline-secret" + RESEND_API_KEY: "test-resend-api-key" + + - name: Install PostgreSQL 18 client tools + run: | + sudo apt-get install -y curl ca-certificates + sudo install -d /usr/share/postgresql-common/pgdg + sudo curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc + sudo sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' + sudo apt-get update + sudo apt-get install -y postgresql-client-18 + pg_dump --version + + - name: Run tests with coverage + working-directory: backend + run: pytest --cov=app --cov-report=xml + env: + FLASK_ENV: "testing" + SECRET_KEY: "test-secret-key" + TEST_DATABASE_URL: "postgresql://usuario:password@localhost:5432/skillstat_test" + JWT_SECRET_KEY: "test-jwt-secret" + GOOGLE_CLIENT_ID: "test-google-client-id" + FRONTEND_BASE_URL: "http://localhost:5500/frontend" + PIPELINE_TRIGGER_SECRET: "test-pipeline-secret" + RESEND_API_KEY: "test-resend-api-key" + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: backend/coverage.xml + fail_ci_if_error: false diff --git a/backend/migrations/versions/a1b2c3d4e5f6_fix_sync_categories_id_seq_after_seed_without_setval.py b/backend/migrations/versions/a1b2c3d4e5f6_fix_sync_categories_id_seq_after_seed_without_setval.py new file mode 100644 index 0000000..fd3c623 --- /dev/null +++ b/backend/migrations/versions/a1b2c3d4e5f6_fix_sync_categories_id_seq_after_seed_without_setval.py @@ -0,0 +1,58 @@ +"""fix: sync categories_id_seq after seed without setval + +Revision ID: a1b2c3d4e5f6 +Revises: 9a104cdbdaed +Create Date: 2026-07-27 00:00:00.000000 + +La migración 401c30988716 sembró las categorías base (ids 1-6) con ids +explícitos usando ON CONFLICT DO NOTHING, pero omitió el ajuste de la +secuencia categories_id_seq. Esto hace que la primera inserción orgánica +de una Category (p.ej. en tests de integración) intente reutilizar el +id=1 ya ocupado, produciendo: + + duplicate key value violates unique constraint "categories_pkey" + +Este patrón es idéntico al que afectó a cities y se corrigió en +9a104cdbdaed. Se aplica la misma solución: SELECT setval() con +GREATEST(MAX(id)+1, 7) para que la secuencia arranque por encima del +id más alto ya sembrado (id=6), sin depender de la condición de la tabla. + +La operación es idempotente: si la secuencia ya fue avanzada (p.ej. en +un entorno que ya tenía inserciones orgánicas de categories), GREATEST +garantiza que no la retrocedemos. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = '9a104cdbdaed' +branch_labels = None +depends_on = None + + +def upgrade(): + # Adelantar la secuencia por encima del id máximo ya sembrado (6), + # para que el próximo INSERT orgánico comience desde id=7 como mínimo. + # GREATEST(MAX(id)+1, 7) es defensivo: si ya existen filas con id > 6, + # usamos ese valor; si la tabla solo tiene las filas sembradas (max=6), + # partimos de 7. + op.execute( + sa.text( + """ + SELECT setval( + pg_get_serial_sequence('categories', 'id'), + GREATEST((SELECT MAX(id) + 1 FROM categories), 7) + ) + """ + ) + ) + + +def downgrade(): + # No hay reversión significativa posible para un ajuste de secuencia: + # retroceder la secuencia podría causar colisiones con filas ya + # insertadas en producción. Se documenta explícitamente como decisión + # de diseño, no como omisión. + pass diff --git a/backend/requirements.txt b/backend/requirements.txt index 7b13945..5b500ef 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -46,4 +46,3 @@ Flask-Limiter>=3.8,<4.0 # Testing pytest>=8.0,<9.0 pytest-flask>=1.3,<2.0 -pytest-cov>=5.0,<6.0 diff --git a/backend/tests/integration/test_market_trends_service.py b/backend/tests/integration/test_market_trends_service.py index 164c4c1..a302f0a 100644 --- a/backend/tests/integration/test_market_trends_service.py +++ b/backend/tests/integration/test_market_trends_service.py @@ -18,7 +18,17 @@ # Duplicados deliberadamente aqui (principio DAMP): cada archivo de tests es autocontenido. No se importa desde otros archivos de tests para preservar aislamiento y legibilidad individual. def _make_city(db_session, city_id, name="Mexico Nacional", state="Nacional"): - """ Crea una City con un ID especifico usando INSERT directo para poder controlar el id=1 que necesita el fallback de generate_snapshots(). Usa INSERT con id explicito en lugar de add() para garantizar el id exacto, ya que PostgreSQL asigna secuencias y podria saltarse el 1 si ya hubo inserts previos en la sesion """ + """ Crea una City con un ID especifico usando INSERT directo para poder + controlar el id=1 que necesita el fallback de generate_snapshots(). Usa + INSERT con id explicito en lugar de add() para garantizar el id exacto. + + Si ya existe una City con ese id (p.ej. la fila sembrada por la migración + 9a104cdbdaed para México Nacional), la retorna directamente sin intentar + re-insertarla. Esto hace la función idempotente frente al esquema base + que ya contiene id=1 tras flask db upgrade. """ + existing = db_session.get(City, city_id) + if existing: + return existing city = City(id=city_id, name=name, state=state) db_session.add(city) db_session.flush()