feat(phase-6): Complete testing and deployment setup
Testing: - Add pytest configuration (pytest.ini) - Add test fixtures (tests/conftest.py) - Add ContentGenerator tests (13 tests) - Add ContentScheduler tests (16 tests) - Add PublisherManager tests (16 tests) - All 45 tests passing Production Docker: - Add docker-compose.prod.yml with healthchecks, resource limits - Add Dockerfile.prod with multi-stage build, non-root user - Add nginx.prod.conf with SSL, rate limiting, security headers - Add .env.prod.example template Maintenance Scripts: - Add backup.sh for database and media backups - Add restore.sh for database restoration - Add cleanup.sh for log rotation and Docker cleanup - Add healthcheck.sh with Telegram alerts Documentation: - Add DEPLOY.md with complete deployment guide Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
73
.env.prod.example
Normal file
73
.env.prod.example
Normal file
@@ -0,0 +1,73 @@
|
||||
# ===========================================
|
||||
# Production Environment Variables
|
||||
# Copy to .env.prod and fill in values
|
||||
# ===========================================
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# DATABASE
|
||||
# ─────────────────────────────────────────
|
||||
POSTGRES_USER=social_automation
|
||||
POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
|
||||
POSTGRES_DB=social_automation
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# APPLICATION
|
||||
# ─────────────────────────────────────────
|
||||
SECRET_KEY=GENERATE_A_SECURE_RANDOM_KEY_HERE
|
||||
ENVIRONMENT=production
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# BUSINESS INFO
|
||||
# ─────────────────────────────────────────
|
||||
BUSINESS_NAME="Consultoría AS"
|
||||
BUSINESS_LOCATION="Tijuana, México"
|
||||
BUSINESS_WEBSITE="https://consultoria-as.com"
|
||||
CONTENT_TONE="Profesional pero accesible, técnico cuando es necesario"
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# DEEPSEEK API
|
||||
# ─────────────────────────────────────────
|
||||
DEEPSEEK_API_KEY=your_deepseek_api_key
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# X (TWITTER) API
|
||||
# ─────────────────────────────────────────
|
||||
X_API_KEY=your_x_api_key
|
||||
X_API_SECRET=your_x_api_secret
|
||||
X_ACCESS_TOKEN=your_x_access_token
|
||||
X_ACCESS_SECRET=your_x_access_secret
|
||||
X_BEARER_TOKEN=your_x_bearer_token
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# META (FACEBOOK, INSTAGRAM, THREADS)
|
||||
# ─────────────────────────────────────────
|
||||
META_ACCESS_TOKEN=your_meta_access_token
|
||||
META_APP_ID=your_meta_app_id
|
||||
META_APP_SECRET=your_meta_app_secret
|
||||
|
||||
# Facebook
|
||||
FACEBOOK_PAGE_ID=your_facebook_page_id
|
||||
|
||||
# Instagram
|
||||
INSTAGRAM_ACCOUNT_ID=your_instagram_account_id
|
||||
|
||||
# Threads
|
||||
THREADS_USER_ID=your_threads_user_id
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# IMAGE UPLOAD (ImgBB)
|
||||
# ─────────────────────────────────────────
|
||||
IMGBB_API_KEY=your_imgbb_api_key
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# TELEGRAM NOTIFICATIONS
|
||||
# ─────────────────────────────────────────
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
TELEGRAM_CHAT_ID=your_telegram_chat_id
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# FLOWER (Celery Monitor)
|
||||
# ─────────────────────────────────────────
|
||||
FLOWER_USER=admin
|
||||
FLOWER_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
|
||||
358
DEPLOY.md
Normal file
358
DEPLOY.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# Deployment Guide
|
||||
|
||||
## Social Media Automation System
|
||||
|
||||
Guía completa para desplegar el sistema en producción.
|
||||
|
||||
---
|
||||
|
||||
## Requisitos del Sistema
|
||||
|
||||
### Hardware Mínimo
|
||||
- **CPU**: 2 cores
|
||||
- **RAM**: 4GB
|
||||
- **Disco**: 20GB SSD
|
||||
- **Red**: Conexión estable a internet
|
||||
|
||||
### Software
|
||||
- Docker 24.0+
|
||||
- Docker Compose 2.20+
|
||||
- Git
|
||||
|
||||
### Puertos Requeridos
|
||||
- **80**: HTTP (redirección a HTTPS)
|
||||
- **443**: HTTPS (aplicación principal)
|
||||
|
||||
---
|
||||
|
||||
## Instalación Rápida
|
||||
|
||||
```bash
|
||||
# 1. Clonar repositorio
|
||||
git clone https://git.consultoria-as.com/consultoria-as/social-media-automation.git
|
||||
cd social-media-automation
|
||||
|
||||
# 2. Crear archivo de configuración
|
||||
cp .env.prod.example .env.prod
|
||||
|
||||
# 3. Editar configuración
|
||||
nano .env.prod
|
||||
|
||||
# 4. Generar certificados SSL (ver sección SSL)
|
||||
|
||||
# 5. Iniciar servicios
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 6. Verificar estado
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuración Detallada
|
||||
|
||||
### Variables de Entorno (.env.prod)
|
||||
|
||||
#### Base de Datos
|
||||
```bash
|
||||
POSTGRES_USER=social_automation
|
||||
POSTGRES_PASSWORD=<contraseña_segura>
|
||||
POSTGRES_DB=social_automation
|
||||
```
|
||||
|
||||
#### Aplicación
|
||||
```bash
|
||||
SECRET_KEY=<genera_con: openssl rand -hex 32>
|
||||
ENVIRONMENT=production
|
||||
```
|
||||
|
||||
#### APIs de Redes Sociales
|
||||
|
||||
**X (Twitter)**
|
||||
1. Ve a https://developer.twitter.com
|
||||
2. Crea una app con permisos de lectura/escritura
|
||||
3. Genera tokens de acceso
|
||||
|
||||
```bash
|
||||
X_API_KEY=<api_key>
|
||||
X_API_SECRET=<api_secret>
|
||||
X_ACCESS_TOKEN=<access_token>
|
||||
X_ACCESS_SECRET=<access_secret>
|
||||
X_BEARER_TOKEN=<bearer_token>
|
||||
```
|
||||
|
||||
**Meta (Facebook, Instagram, Threads)**
|
||||
1. Ve a https://developers.facebook.com
|
||||
2. Crea una app tipo "Business"
|
||||
3. Agrega productos: Facebook Login, Instagram Graph API
|
||||
4. Genera token de página con permisos:
|
||||
- `pages_manage_posts`
|
||||
- `pages_read_engagement`
|
||||
- `instagram_basic`
|
||||
- `instagram_content_publish`
|
||||
|
||||
```bash
|
||||
META_ACCESS_TOKEN=<page_access_token>
|
||||
FACEBOOK_PAGE_ID=<page_id>
|
||||
INSTAGRAM_ACCOUNT_ID=<instagram_business_id>
|
||||
THREADS_USER_ID=<threads_user_id>
|
||||
```
|
||||
|
||||
**DeepSeek API**
|
||||
1. Ve a https://platform.deepseek.com
|
||||
2. Genera una API key
|
||||
|
||||
```bash
|
||||
DEEPSEEK_API_KEY=<api_key>
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
```
|
||||
|
||||
**ImgBB (subida de imágenes)**
|
||||
1. Ve a https://api.imgbb.com
|
||||
2. Genera una API key
|
||||
|
||||
```bash
|
||||
IMGBB_API_KEY=<api_key>
|
||||
```
|
||||
|
||||
**Telegram (notificaciones)**
|
||||
1. Habla con @BotFather en Telegram
|
||||
2. Crea un bot con `/newbot`
|
||||
3. Obtén el chat_id con @userinfobot
|
||||
|
||||
```bash
|
||||
TELEGRAM_BOT_TOKEN=<bot_token>
|
||||
TELEGRAM_CHAT_ID=<chat_id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Certificados SSL
|
||||
|
||||
### Opción 1: Let's Encrypt (Recomendado)
|
||||
|
||||
```bash
|
||||
# Instalar certbot
|
||||
apt install certbot
|
||||
|
||||
# Generar certificados
|
||||
certbot certonly --standalone -d tu-dominio.com
|
||||
|
||||
# Copiar a nginx
|
||||
cp /etc/letsencrypt/live/tu-dominio.com/fullchain.pem nginx/ssl/
|
||||
cp /etc/letsencrypt/live/tu-dominio.com/privkey.pem nginx/ssl/
|
||||
|
||||
# Renovación automática (agregar a crontab)
|
||||
0 0 1 * * certbot renew --quiet && docker-compose -f docker-compose.prod.yml restart nginx
|
||||
```
|
||||
|
||||
### Opción 2: Certificado Autofirmado (Solo desarrollo)
|
||||
|
||||
```bash
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout nginx/ssl/privkey.pem \
|
||||
-out nginx/ssl/fullchain.pem \
|
||||
-subj "/CN=localhost"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comandos Útiles
|
||||
|
||||
### Gestión de Servicios
|
||||
|
||||
```bash
|
||||
# Iniciar todos los servicios
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Detener todos los servicios
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# Ver logs en tiempo real
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Ver logs de un servicio específico
|
||||
docker-compose -f docker-compose.prod.yml logs -f app
|
||||
|
||||
# Reiniciar un servicio
|
||||
docker-compose -f docker-compose.prod.yml restart app
|
||||
|
||||
# Ver estado de los contenedores
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
### Base de Datos
|
||||
|
||||
```bash
|
||||
# Ejecutar migraciones
|
||||
docker-compose -f docker-compose.prod.yml exec app alembic upgrade head
|
||||
|
||||
# Acceder a PostgreSQL
|
||||
docker exec -it social-automation-db psql -U social_automation
|
||||
|
||||
# Backup manual
|
||||
./scripts/maintenance/backup.sh
|
||||
|
||||
# Restaurar backup
|
||||
./scripts/maintenance/restore.sh backups/database/db_backup_YYYYMMDD.sql.gz
|
||||
```
|
||||
|
||||
### Celery
|
||||
|
||||
```bash
|
||||
# Ver tareas activas
|
||||
docker exec social-automation-flower celery -A app.worker.celery_app inspect active
|
||||
|
||||
# Ver tareas programadas
|
||||
docker exec social-automation-flower celery -A app.worker.celery_app inspect scheduled
|
||||
|
||||
# Purgar cola
|
||||
docker exec social-automation-worker celery -A app.worker.celery_app purge -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mantenimiento
|
||||
|
||||
### Tareas Programadas (Crontab)
|
||||
|
||||
```bash
|
||||
# Editar crontab
|
||||
crontab -e
|
||||
|
||||
# Agregar las siguientes líneas:
|
||||
|
||||
# Backup diario a las 2 AM
|
||||
0 2 * * * /ruta/al/proyecto/scripts/maintenance/backup.sh >> /var/log/backup.log 2>&1
|
||||
|
||||
# Limpieza semanal los domingos a las 3 AM
|
||||
0 3 * * 0 /ruta/al/proyecto/scripts/maintenance/cleanup.sh >> /var/log/cleanup.log 2>&1
|
||||
|
||||
# Health check cada 5 minutos
|
||||
*/5 * * * * /ruta/al/proyecto/scripts/maintenance/healthcheck.sh > /dev/null 2>&1
|
||||
```
|
||||
|
||||
### Actualizaciones
|
||||
|
||||
```bash
|
||||
# 1. Hacer backup
|
||||
./scripts/maintenance/backup.sh
|
||||
|
||||
# 2. Obtener cambios
|
||||
git pull origin main
|
||||
|
||||
# 3. Reconstruir imágenes
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
|
||||
# 4. Aplicar migraciones
|
||||
docker-compose -f docker-compose.prod.yml exec app alembic upgrade head
|
||||
|
||||
# 5. Reiniciar servicios
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 6. Verificar
|
||||
./scripts/maintenance/healthcheck.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### La aplicación no inicia
|
||||
|
||||
```bash
|
||||
# Ver logs detallados
|
||||
docker-compose -f docker-compose.prod.yml logs app
|
||||
|
||||
# Verificar variables de entorno
|
||||
docker-compose -f docker-compose.prod.yml exec app env | grep -E "(DATABASE|REDIS|SECRET)"
|
||||
|
||||
# Probar conexión a DB
|
||||
docker exec social-automation-db pg_isready -U social_automation
|
||||
```
|
||||
|
||||
### Error de conexión a la base de datos
|
||||
|
||||
```bash
|
||||
# Verificar que DB esté corriendo
|
||||
docker ps | grep db
|
||||
|
||||
# Verificar health check
|
||||
docker inspect social-automation-db | grep -A 10 Health
|
||||
|
||||
# Reiniciar DB
|
||||
docker-compose -f docker-compose.prod.yml restart db
|
||||
```
|
||||
|
||||
### Worker no procesa tareas
|
||||
|
||||
```bash
|
||||
# Ver estado del worker
|
||||
docker-compose -f docker-compose.prod.yml logs worker
|
||||
|
||||
# Verificar Redis
|
||||
docker exec social-automation-redis redis-cli ping
|
||||
|
||||
# Reiniciar worker y beat
|
||||
docker-compose -f docker-compose.prod.yml restart worker beat
|
||||
```
|
||||
|
||||
### Error 502 Bad Gateway
|
||||
|
||||
```bash
|
||||
# Verificar que app esté respondiendo
|
||||
curl http://localhost:8000/api/health
|
||||
|
||||
# Ver logs de nginx
|
||||
docker-compose -f docker-compose.prod.yml logs nginx
|
||||
|
||||
# Reiniciar nginx
|
||||
docker-compose -f docker-compose.prod.yml restart nginx
|
||||
```
|
||||
|
||||
### Alto uso de disco
|
||||
|
||||
```bash
|
||||
# Ejecutar limpieza
|
||||
./scripts/maintenance/cleanup.sh
|
||||
|
||||
# Limpiar Docker
|
||||
docker system prune -a --volumes
|
||||
|
||||
# Verificar tamaño de backups
|
||||
du -sh backups/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Seguridad
|
||||
|
||||
### Checklist de Producción
|
||||
|
||||
- [ ] Cambiar todas las contraseñas por defecto
|
||||
- [ ] Generar SECRET_KEY único
|
||||
- [ ] Configurar certificados SSL válidos
|
||||
- [ ] Configurar firewall (solo puertos 80, 443)
|
||||
- [ ] Configurar backups automáticos
|
||||
- [ ] Configurar monitoreo y alertas
|
||||
- [ ] Habilitar autenticación en Flower
|
||||
|
||||
### Firewall (UFW)
|
||||
|
||||
```bash
|
||||
# Configurar firewall básico
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow ssh
|
||||
ufw allow 80
|
||||
ufw allow 443
|
||||
ufw enable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Soporte
|
||||
|
||||
- **Repositorio**: https://git.consultoria-as.com/consultoria-as/social-media-automation
|
||||
- **Documentación API**: https://tu-dominio.com/docs
|
||||
- **Monitor Celery**: https://tu-dominio.com/flower/
|
||||
88
Dockerfile.prod
Normal file
88
Dockerfile.prod
Normal file
@@ -0,0 +1,88 @@
|
||||
# ===========================================
|
||||
# Production Dockerfile
|
||||
# Multi-stage build for smaller image
|
||||
# ===========================================
|
||||
|
||||
# Stage 1: Build
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create virtualenv
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Stage 2: Production
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies only
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
chromium \
|
||||
chromium-driver \
|
||||
fonts-liberation \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libatspi2.0-0 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
xdg-utils \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
# Copy virtualenv from builder
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
|
||||
|
||||
# Copy application code
|
||||
COPY --chown=appuser:appgroup . .
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /app/uploads /app/logs \
|
||||
&& chown -R appuser:appgroup /app/uploads /app/logs
|
||||
|
||||
# Environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV CHROME_BIN=/usr/bin/chromium
|
||||
ENV CHROMIUM_FLAGS="--no-sandbox --disable-dev-shm-usage"
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/api/health || exit 1
|
||||
|
||||
# Default command (can be overridden)
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
240
docker-compose.prod.yml
Normal file
240
docker-compose.prod.yml
Normal file
@@ -0,0 +1,240 @@
|
||||
version: '3.8'
|
||||
|
||||
# ===========================================
|
||||
# PRODUCCIÓN - Social Media Automation
|
||||
# Uso: docker-compose -f docker-compose.prod.yml up -d
|
||||
# ===========================================
|
||||
|
||||
services:
|
||||
# ===========================================
|
||||
# APLICACIÓN PRINCIPAL (FastAPI)
|
||||
# ===========================================
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.prod
|
||||
container_name: social-automation-app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
env_file:
|
||||
- .env.prod
|
||||
volumes:
|
||||
- uploaded_images:/app/uploads
|
||||
- logs:/app/logs
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: always
|
||||
networks:
|
||||
- social-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
# ===========================================
|
||||
# CELERY WORKER (Procesamiento de tareas)
|
||||
# ===========================================
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.prod
|
||||
container_name: social-automation-worker
|
||||
command: celery -A app.worker.celery_app worker --loglevel=warning --concurrency=4
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
env_file:
|
||||
- .env.prod
|
||||
volumes:
|
||||
- uploaded_images:/app/uploads
|
||||
- logs:/app/logs
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: always
|
||||
networks:
|
||||
- social-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
|
||||
# ===========================================
|
||||
# CELERY BEAT (Programador de tareas)
|
||||
# ===========================================
|
||||
beat:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.prod
|
||||
container_name: social-automation-beat
|
||||
command: celery -A app.worker.celery_app beat --loglevel=warning
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
env_file:
|
||||
- .env.prod
|
||||
volumes:
|
||||
- logs:/app/logs
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: always
|
||||
networks:
|
||||
- social-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
# ===========================================
|
||||
# FLOWER (Monitor de Celery) - Solo acceso interno
|
||||
# ===========================================
|
||||
flower:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.prod
|
||||
container_name: social-automation-flower
|
||||
command: celery -A app.worker.celery_app flower --port=5555 --basic_auth=${FLOWER_USER}:${FLOWER_PASSWORD}
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
env_file:
|
||||
- .env.prod
|
||||
depends_on:
|
||||
- redis
|
||||
- worker
|
||||
restart: always
|
||||
networks:
|
||||
- social-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
# ===========================================
|
||||
# POSTGRESQL (Base de datos)
|
||||
# ===========================================
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: social-automation-db
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./backups:/backups
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: always
|
||||
networks:
|
||||
- social-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
|
||||
# ===========================================
|
||||
# REDIS (Cola de mensajes)
|
||||
# ===========================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: social-automation-redis
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: always
|
||||
networks:
|
||||
- social-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
# ===========================================
|
||||
# NGINX (Reverse Proxy + SSL)
|
||||
# ===========================================
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: social-automation-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
- ./dashboard/static:/usr/share/nginx/html/static:ro
|
||||
- nginx_logs:/var/log/nginx
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
restart: always
|
||||
networks:
|
||||
- social-network
|
||||
healthcheck:
|
||||
test: ["CMD", "nginx", "-t"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.25'
|
||||
memory: 128M
|
||||
|
||||
# ===========================================
|
||||
# VOLÚMENES
|
||||
# ===========================================
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
uploaded_images:
|
||||
driver: local
|
||||
logs:
|
||||
driver: local
|
||||
nginx_logs:
|
||||
driver: local
|
||||
|
||||
# ===========================================
|
||||
# REDES
|
||||
# ===========================================
|
||||
networks:
|
||||
social-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
driver: default
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
195
nginx/nginx.prod.conf
Normal file
195
nginx/nginx.prod.conf
Normal file
@@ -0,0 +1,195 @@
|
||||
# ===========================================
|
||||
# NGINX Production Configuration
|
||||
# Social Media Automation System
|
||||
# ===========================================
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging format
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for" '
|
||||
'rt=$request_time uct="$upstream_connect_time" '
|
||||
'uht="$upstream_header_time" urt="$upstream_response_time"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Performance
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript
|
||||
application/xml application/xml+rss text/javascript application/x-font-ttf
|
||||
font/opentype image/svg+xml;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
|
||||
# Upstreams
|
||||
upstream app {
|
||||
server app:8000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
upstream flower {
|
||||
server flower:5555;
|
||||
}
|
||||
|
||||
# HTTP - Redirect to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://app/api/health;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 5s;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS - Main server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||
|
||||
# SSL Security
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# HSTS
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Client body size (for image uploads)
|
||||
client_max_body_size 10M;
|
||||
|
||||
# Static files
|
||||
location /static {
|
||||
alias /usr/share/nginx/html/static;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# API endpoints
|
||||
location /api {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
|
||||
proxy_pass http://app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Login rate limiting
|
||||
location /api/auth/login {
|
||||
limit_req zone=login burst=5 nodelay;
|
||||
|
||||
proxy_pass http://app;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Dashboard
|
||||
location /dashboard {
|
||||
proxy_pass http://app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Flower (Celery monitor) - Protected access
|
||||
location /flower/ {
|
||||
# Optional: IP whitelist
|
||||
# allow 192.168.1.0/24;
|
||||
# deny all;
|
||||
|
||||
proxy_pass http://flower/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_redirect off;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
proxy_pass http://app/api/health;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 5s;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Root
|
||||
location / {
|
||||
proxy_pass http://app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
10
pytest.ini
Normal file
10
pytest.ini
Normal file
@@ -0,0 +1,10 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
asyncio_mode = auto
|
||||
addopts = -v --tb=short
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
ignore::PendingDeprecationWarning
|
||||
121
scripts/maintenance/backup.sh
Executable file
121
scripts/maintenance/backup.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
# ===========================================
|
||||
# Backup Script for Social Media Automation
|
||||
# Run daily via cron:
|
||||
# 0 2 * * * /path/to/backup.sh >> /var/log/backup.log 2>&1
|
||||
# ===========================================
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR="${BACKUP_DIR:-/root/Facebook-X-Threads-Automation/backups}"
|
||||
RETENTION_DAYS="${RETENTION_DAYS:-7}"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-social-automation-db}"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
log "${RED}ERROR: $1${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
success() {
|
||||
log "${GREEN}$1${NC}"
|
||||
}
|
||||
|
||||
warning() {
|
||||
log "${YELLOW}$1${NC}"
|
||||
}
|
||||
|
||||
# Create backup directory if not exists
|
||||
mkdir -p "$BACKUP_DIR"/{database,media}
|
||||
|
||||
log "Starting backup process..."
|
||||
|
||||
# ===========================================
|
||||
# 1. DATABASE BACKUP
|
||||
# ===========================================
|
||||
log "Backing up PostgreSQL database..."
|
||||
|
||||
DB_BACKUP_FILE="$BACKUP_DIR/database/db_backup_$TIMESTAMP.sql.gz"
|
||||
|
||||
# Check if container is running
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
error "Database container '$CONTAINER_NAME' is not running"
|
||||
fi
|
||||
|
||||
# Get database credentials from container
|
||||
POSTGRES_USER=$(docker exec $CONTAINER_NAME printenv POSTGRES_USER 2>/dev/null || echo "social_user")
|
||||
POSTGRES_DB=$(docker exec $CONTAINER_NAME printenv POSTGRES_DB 2>/dev/null || echo "social_automation")
|
||||
|
||||
# Perform backup
|
||||
if docker exec $CONTAINER_NAME pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip > "$DB_BACKUP_FILE"; then
|
||||
DB_SIZE=$(du -h "$DB_BACKUP_FILE" | cut -f1)
|
||||
success "Database backup completed: $DB_BACKUP_FILE ($DB_SIZE)"
|
||||
else
|
||||
error "Database backup failed"
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# 2. MEDIA FILES BACKUP
|
||||
# ===========================================
|
||||
log "Backing up media files..."
|
||||
|
||||
MEDIA_BACKUP_FILE="$BACKUP_DIR/media/media_backup_$TIMESTAMP.tar.gz"
|
||||
UPLOADS_DIR="/root/Facebook-X-Threads-Automation/uploads"
|
||||
|
||||
if [ -d "$UPLOADS_DIR" ] && [ "$(ls -A $UPLOADS_DIR 2>/dev/null)" ]; then
|
||||
if tar -czf "$MEDIA_BACKUP_FILE" -C "$(dirname $UPLOADS_DIR)" "$(basename $UPLOADS_DIR)"; then
|
||||
MEDIA_SIZE=$(du -h "$MEDIA_BACKUP_FILE" | cut -f1)
|
||||
success "Media backup completed: $MEDIA_BACKUP_FILE ($MEDIA_SIZE)"
|
||||
else
|
||||
warning "Media backup failed or partially completed"
|
||||
fi
|
||||
else
|
||||
warning "No media files to backup"
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# 3. CLEANUP OLD BACKUPS
|
||||
# ===========================================
|
||||
log "Cleaning up backups older than $RETENTION_DAYS days..."
|
||||
|
||||
# Count files before cleanup
|
||||
DB_BEFORE=$(find "$BACKUP_DIR/database" -name "*.sql.gz" -type f 2>/dev/null | wc -l)
|
||||
MEDIA_BEFORE=$(find "$BACKUP_DIR/media" -name "*.tar.gz" -type f 2>/dev/null | wc -l)
|
||||
|
||||
# Delete old files
|
||||
find "$BACKUP_DIR/database" -name "*.sql.gz" -type f -mtime +$RETENTION_DAYS -delete 2>/dev/null || true
|
||||
find "$BACKUP_DIR/media" -name "*.tar.gz" -type f -mtime +$RETENTION_DAYS -delete 2>/dev/null || true
|
||||
|
||||
# Count files after cleanup
|
||||
DB_AFTER=$(find "$BACKUP_DIR/database" -name "*.sql.gz" -type f 2>/dev/null | wc -l)
|
||||
MEDIA_AFTER=$(find "$BACKUP_DIR/media" -name "*.tar.gz" -type f 2>/dev/null | wc -l)
|
||||
|
||||
DB_DELETED=$((DB_BEFORE - DB_AFTER))
|
||||
MEDIA_DELETED=$((MEDIA_BEFORE - MEDIA_AFTER))
|
||||
|
||||
if [ $DB_DELETED -gt 0 ] || [ $MEDIA_DELETED -gt 0 ]; then
|
||||
log "Deleted $DB_DELETED database backup(s) and $MEDIA_DELETED media backup(s)"
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# 4. SUMMARY
|
||||
# ===========================================
|
||||
log "─────────────────────────────────────────"
|
||||
log "Backup Summary:"
|
||||
log " Database backups: $DB_AFTER"
|
||||
log " Media backups: $MEDIA_AFTER"
|
||||
log " Total size: $(du -sh $BACKUP_DIR | cut -f1)"
|
||||
log "─────────────────────────────────────────"
|
||||
|
||||
success "Backup process completed successfully!"
|
||||
121
scripts/maintenance/cleanup.sh
Executable file
121
scripts/maintenance/cleanup.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
# ===========================================
|
||||
# Cleanup Script for Social Media Automation
|
||||
# Run weekly via cron:
|
||||
# 0 3 * * 0 /path/to/cleanup.sh >> /var/log/cleanup.log 2>&1
|
||||
# ===========================================
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
PROJECT_DIR="${PROJECT_DIR:-/root/Facebook-X-Threads-Automation}"
|
||||
LOG_RETENTION_DAYS="${LOG_RETENTION_DAYS:-30}"
|
||||
DOCKER_LOG_MAX_SIZE="${DOCKER_LOG_MAX_SIZE:-100m}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
success() {
|
||||
log "${GREEN}$1${NC}"
|
||||
}
|
||||
|
||||
warning() {
|
||||
log "${YELLOW}$1${NC}"
|
||||
}
|
||||
|
||||
log "Starting cleanup process..."
|
||||
|
||||
# ===========================================
|
||||
# 1. CLEAN DOCKER LOGS
|
||||
# ===========================================
|
||||
log "Cleaning Docker container logs..."
|
||||
|
||||
# Truncate Docker logs (requires root)
|
||||
if [ -d /var/lib/docker/containers ]; then
|
||||
for container_dir in /var/lib/docker/containers/*/; do
|
||||
log_file="${container_dir}*-json.log"
|
||||
for f in $log_file; do
|
||||
if [ -f "$f" ]; then
|
||||
size_before=$(du -h "$f" | cut -f1)
|
||||
if truncate -s 0 "$f" 2>/dev/null; then
|
||||
log " Truncated: $(basename $(dirname $f)) ($size_before)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
success "Docker logs cleaned"
|
||||
else
|
||||
warning "Docker log directory not found (might need sudo)"
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# 2. CLEAN APPLICATION LOGS
|
||||
# ===========================================
|
||||
log "Cleaning application logs older than $LOG_RETENTION_DAYS days..."
|
||||
|
||||
if [ -d "$PROJECT_DIR/logs" ]; then
|
||||
count=$(find "$PROJECT_DIR/logs" -name "*.log" -type f -mtime +$LOG_RETENTION_DAYS 2>/dev/null | wc -l)
|
||||
find "$PROJECT_DIR/logs" -name "*.log" -type f -mtime +$LOG_RETENTION_DAYS -delete 2>/dev/null || true
|
||||
log " Deleted $count old log file(s)"
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# 3. CLEAN NGINX LOGS
|
||||
# ===========================================
|
||||
log "Rotating nginx logs..."
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -q "social-automation-nginx"; then
|
||||
docker exec social-automation-nginx nginx -s reopen 2>/dev/null && \
|
||||
success "Nginx logs rotated" || warning "Could not rotate nginx logs"
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# 4. CLEAN DOCKER SYSTEM
|
||||
# ===========================================
|
||||
log "Cleaning Docker system..."
|
||||
|
||||
# Remove unused images, containers, networks
|
||||
docker system prune -f --volumes 2>/dev/null && \
|
||||
success "Docker system cleaned" || warning "Could not clean Docker system"
|
||||
|
||||
# Remove dangling images
|
||||
dangling=$(docker images -f "dangling=true" -q 2>/dev/null | wc -l)
|
||||
if [ $dangling -gt 0 ]; then
|
||||
docker rmi $(docker images -f "dangling=true" -q) 2>/dev/null || true
|
||||
log " Removed $dangling dangling image(s)"
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# 5. CLEAN TEMP FILES
|
||||
# ===========================================
|
||||
log "Cleaning temporary files..."
|
||||
|
||||
# Python cache
|
||||
find "$PROJECT_DIR" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$PROJECT_DIR" -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
find "$PROJECT_DIR" -type f -name "*.pyo" -delete 2>/dev/null || true
|
||||
|
||||
# Pytest cache
|
||||
rm -rf "$PROJECT_DIR/.pytest_cache" 2>/dev/null || true
|
||||
|
||||
success "Temporary files cleaned"
|
||||
|
||||
# ===========================================
|
||||
# 6. DISK USAGE REPORT
|
||||
# ===========================================
|
||||
log "─────────────────────────────────────────"
|
||||
log "Disk Usage Report:"
|
||||
log " Project: $(du -sh $PROJECT_DIR 2>/dev/null | cut -f1)"
|
||||
log " Backups: $(du -sh $PROJECT_DIR/backups 2>/dev/null | cut -f1 || echo 'N/A')"
|
||||
log " Docker: $(docker system df --format '{{.Size}}' 2>/dev/null | head -1 || echo 'N/A')"
|
||||
log " Disk: $(df -h / | awk 'NR==2 {print $4 " free of " $2}')"
|
||||
log "─────────────────────────────────────────"
|
||||
|
||||
success "Cleanup process completed!"
|
||||
154
scripts/maintenance/healthcheck.sh
Executable file
154
scripts/maintenance/healthcheck.sh
Executable file
@@ -0,0 +1,154 @@
|
||||
#!/bin/bash
|
||||
# ===========================================
|
||||
# Health Check Script for Social Media Automation
|
||||
# Run every 5 minutes via cron:
|
||||
# */5 * * * * /path/to/healthcheck.sh
|
||||
# ===========================================
|
||||
|
||||
# Configuration
|
||||
APP_URL="${APP_URL:-http://localhost:8000}"
|
||||
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
|
||||
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
|
||||
ALERT_FILE="/tmp/social_automation_alert_sent"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
send_telegram() {
|
||||
if [ -n "$TELEGRAM_BOT_TOKEN" ] && [ -n "$TELEGRAM_CHAT_ID" ]; then
|
||||
message="$1"
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||
-d "text=${message}" \
|
||||
-d "parse_mode=HTML" > /dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
check_service() {
|
||||
local name=$1
|
||||
local container=$2
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${container}$"; then
|
||||
echo -e "${GREEN}✓${NC} $name"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗${NC} $name"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
log "Running health checks..."
|
||||
|
||||
ERRORS=0
|
||||
STATUS=""
|
||||
|
||||
# ===========================================
|
||||
# 1. CHECK DOCKER CONTAINERS
|
||||
# ===========================================
|
||||
echo ""
|
||||
echo "Container Status:"
|
||||
|
||||
check_service "App (FastAPI)" "social-automation-app" || ((ERRORS++))
|
||||
check_service "Worker (Celery)" "social-automation-worker" || ((ERRORS++))
|
||||
check_service "Beat (Scheduler)" "social-automation-beat" || ((ERRORS++))
|
||||
check_service "Database (PostgreSQL)" "social-automation-db" || ((ERRORS++))
|
||||
check_service "Redis" "social-automation-redis" || ((ERRORS++))
|
||||
check_service "Nginx" "social-automation-nginx" || ((ERRORS++))
|
||||
|
||||
# ===========================================
|
||||
# 2. CHECK API HEALTH
|
||||
# ===========================================
|
||||
echo ""
|
||||
echo "API Status:"
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$APP_URL/api/health" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo -e "${GREEN}✓${NC} API responding (HTTP $HTTP_CODE)"
|
||||
else
|
||||
echo -e "${RED}✗${NC} API not responding (HTTP $HTTP_CODE)"
|
||||
((ERRORS++))
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# 3. CHECK DATABASE CONNECTION
|
||||
# ===========================================
|
||||
echo ""
|
||||
echo "Database Status:"
|
||||
|
||||
if docker exec social-automation-db pg_isready -U social_user -d social_automation > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓${NC} PostgreSQL accepting connections"
|
||||
else
|
||||
echo -e "${RED}✗${NC} PostgreSQL not accepting connections"
|
||||
((ERRORS++))
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# 4. CHECK REDIS
|
||||
# ===========================================
|
||||
echo ""
|
||||
echo "Redis Status:"
|
||||
|
||||
if docker exec social-automation-redis redis-cli ping 2>/dev/null | grep -q "PONG"; then
|
||||
echo -e "${GREEN}✓${NC} Redis responding"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Redis not responding"
|
||||
((ERRORS++))
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# 5. CHECK DISK SPACE
|
||||
# ===========================================
|
||||
echo ""
|
||||
echo "System Resources:"
|
||||
|
||||
DISK_USAGE=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
|
||||
if [ "$DISK_USAGE" -lt 90 ]; then
|
||||
echo -e "${GREEN}✓${NC} Disk usage: ${DISK_USAGE}%"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Disk usage: ${DISK_USAGE}% (CRITICAL)"
|
||||
((ERRORS++))
|
||||
fi
|
||||
|
||||
# Memory
|
||||
MEM_USAGE=$(free | awk 'NR==2 {printf "%.0f", $3/$2*100}')
|
||||
if [ "$MEM_USAGE" -lt 90 ]; then
|
||||
echo -e "${GREEN}✓${NC} Memory usage: ${MEM_USAGE}%"
|
||||
else
|
||||
echo -e "${YELLOW}!${NC} Memory usage: ${MEM_USAGE}% (HIGH)"
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# 6. SUMMARY & ALERTS
|
||||
# ===========================================
|
||||
echo ""
|
||||
echo "─────────────────────────────────────────"
|
||||
|
||||
if [ $ERRORS -eq 0 ]; then
|
||||
echo -e "${GREEN}All systems operational${NC}"
|
||||
|
||||
# Clear alert file if exists (system recovered)
|
||||
if [ -f "$ALERT_FILE" ]; then
|
||||
rm "$ALERT_FILE"
|
||||
send_telegram "✅ <b>Social Media Automation - RECOVERED</b>%0A%0AAll systems are back to normal."
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}$ERRORS error(s) detected${NC}"
|
||||
|
||||
# Send alert only if not already sent
|
||||
if [ ! -f "$ALERT_FILE" ]; then
|
||||
touch "$ALERT_FILE"
|
||||
send_telegram "🚨 <b>Social Media Automation - ALERT</b>%0A%0A$ERRORS service(s) are down!%0ACheck server immediately."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "─────────────────────────────────────────"
|
||||
|
||||
exit $ERRORS
|
||||
94
scripts/maintenance/restore.sh
Executable file
94
scripts/maintenance/restore.sh
Executable file
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
# ===========================================
|
||||
# Restore Script for Social Media Automation
|
||||
# Usage: ./restore.sh [backup_file]
|
||||
# ===========================================
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR="${BACKUP_DIR:-/root/Facebook-X-Threads-Automation/backups}"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-social-automation-db}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
log "${RED}ERROR: $1${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
success() {
|
||||
log "${GREEN}$1${NC}"
|
||||
}
|
||||
|
||||
warning() {
|
||||
log "${YELLOW}$1${NC}"
|
||||
}
|
||||
|
||||
# Check if backup file provided
|
||||
if [ -z "$1" ]; then
|
||||
log "Available database backups:"
|
||||
echo ""
|
||||
ls -lh "$BACKUP_DIR/database/"*.sql.gz 2>/dev/null || echo " No backups found"
|
||||
echo ""
|
||||
log "Usage: $0 <backup_file.sql.gz>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_FILE="$1"
|
||||
|
||||
# Check if file exists
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
# Try with backup dir prefix
|
||||
if [ -f "$BACKUP_DIR/database/$BACKUP_FILE" ]; then
|
||||
BACKUP_FILE="$BACKUP_DIR/database/$BACKUP_FILE"
|
||||
else
|
||||
error "Backup file not found: $BACKUP_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Backup file: $BACKUP_FILE"
|
||||
|
||||
# Confirm restore
|
||||
warning "WARNING: This will overwrite the current database!"
|
||||
read -p "Are you sure you want to continue? (yes/no): " CONFIRM
|
||||
|
||||
if [ "$CONFIRM" != "yes" ]; then
|
||||
log "Restore cancelled"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if container is running
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
error "Database container '$CONTAINER_NAME' is not running"
|
||||
fi
|
||||
|
||||
# Get database credentials
|
||||
POSTGRES_USER=$(docker exec $CONTAINER_NAME printenv POSTGRES_USER 2>/dev/null || echo "social_user")
|
||||
POSTGRES_DB=$(docker exec $CONTAINER_NAME printenv POSTGRES_DB 2>/dev/null || echo "social_automation")
|
||||
|
||||
log "Restoring database..."
|
||||
|
||||
# Drop existing connections and recreate database
|
||||
docker exec $CONTAINER_NAME psql -U "$POSTGRES_USER" -c "
|
||||
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE pg_stat_activity.datname = '$POSTGRES_DB'
|
||||
AND pid <> pg_backend_pid();" postgres 2>/dev/null || true
|
||||
|
||||
# Restore
|
||||
if gunzip -c "$BACKUP_FILE" | docker exec -i $CONTAINER_NAME psql -U "$POSTGRES_USER" "$POSTGRES_DB"; then
|
||||
success "Database restored successfully!"
|
||||
else
|
||||
error "Database restore failed"
|
||||
fi
|
||||
|
||||
log "Restore completed. Please restart the application containers."
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the social media automation system."""
|
||||
124
tests/conftest.py
Normal file
124
tests/conftest.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Test fixtures and configuration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
# In-memory SQLite for testing
|
||||
TEST_DATABASE_URL = "sqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_engine():
|
||||
"""Create an in-memory SQLite engine for testing."""
|
||||
engine = create_engine(
|
||||
TEST_DATABASE_URL,
|
||||
connect_args={"check_same_thread": False}
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield engine
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_session(test_engine):
|
||||
"""Create a test database session."""
|
||||
TestSessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=test_engine
|
||||
)
|
||||
session = TestSessionLocal()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_client():
|
||||
"""Mock OpenAI client for DeepSeek API tests."""
|
||||
mock_client = MagicMock()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "Generated test content #TechTip #AI"
|
||||
|
||||
mock_client.chat.completions.create.return_value = mock_response
|
||||
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_httpx_client():
|
||||
"""Mock httpx client for API calls."""
|
||||
mock_client = AsyncMock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"id": "123", "success": True}
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client.post.return_value = mock_response
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_product():
|
||||
"""Sample product data for testing."""
|
||||
return {
|
||||
"name": "Laptop HP Pavilion",
|
||||
"description": "Laptop potente para trabajo y gaming",
|
||||
"price": 15999.00,
|
||||
"category": "laptops",
|
||||
"specs": {
|
||||
"processor": "Intel Core i5",
|
||||
"ram": "16GB",
|
||||
"storage": "512GB SSD"
|
||||
},
|
||||
"highlights": ["Alta velocidad", "Diseño compacto", "Garantía 2 años"]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_service():
|
||||
"""Sample service data for testing."""
|
||||
return {
|
||||
"name": "Automatización con IA",
|
||||
"description": "Automatiza tus procesos con inteligencia artificial",
|
||||
"category": "ai_automation",
|
||||
"target_sectors": ["retail", "manufactura", "servicios"],
|
||||
"benefits": ["Reduce costos", "Aumenta productividad", "24/7 operación"],
|
||||
"call_to_action": "Agenda una demo gratuita"
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_interaction():
|
||||
"""Sample interaction data for testing."""
|
||||
return {
|
||||
"content": "¿Qué procesador recomiendas para edición de video?",
|
||||
"type": "comment",
|
||||
"platform": "x",
|
||||
"author": "user123"
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings():
|
||||
"""Mock settings for testing."""
|
||||
with patch('app.core.config.settings') as mock:
|
||||
mock.DEEPSEEK_API_KEY = "test-api-key"
|
||||
mock.DEEPSEEK_BASE_URL = "https://api.deepseek.com"
|
||||
mock.BUSINESS_NAME = "Consultoría AS"
|
||||
mock.BUSINESS_LOCATION = "Tijuana, México"
|
||||
mock.BUSINESS_WEBSITE = "https://consultoria-as.com"
|
||||
mock.CONTENT_TONE = "Profesional pero accesible"
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixed_datetime():
|
||||
"""Fixed datetime for consistent testing."""
|
||||
return datetime(2024, 6, 15, 10, 0, 0) # Saturday 10:00
|
||||
180
tests/test_content_generator.py
Normal file
180
tests/test_content_generator.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Tests for ContentGenerator service.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
|
||||
class TestContentGenerator:
|
||||
"""Tests for the ContentGenerator class."""
|
||||
|
||||
@pytest.fixture
|
||||
def generator(self, mock_settings):
|
||||
"""Create a ContentGenerator instance with mocked client."""
|
||||
with patch('app.services.content_generator.OpenAI') as mock_openai:
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "Test content #AI #Tech"
|
||||
mock_client.chat.completions.create.return_value = mock_response
|
||||
mock_openai.return_value = mock_client
|
||||
|
||||
from app.services.content_generator import ContentGenerator
|
||||
gen = ContentGenerator()
|
||||
gen._client = mock_client
|
||||
yield gen
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_tip_tech(self, generator):
|
||||
"""Test generating a tech tip."""
|
||||
result = await generator.generate_tip_tech(
|
||||
category="seguridad",
|
||||
platform="x"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) > 0
|
||||
generator.client.chat.completions.create.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_tip_tech_with_template(self, generator):
|
||||
"""Test generating a tech tip with a template."""
|
||||
result = await generator.generate_tip_tech(
|
||||
category="productividad",
|
||||
platform="threads",
|
||||
template="Tip del día: {tip}"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
call_args = generator.client.chat.completions.create.call_args
|
||||
assert "template" in str(call_args).lower() or "TEMPLATE" in str(call_args)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_product_post(self, generator, sample_product):
|
||||
"""Test generating a product post."""
|
||||
result = await generator.generate_product_post(
|
||||
product=sample_product,
|
||||
platform="instagram"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
call_args = generator.client.chat.completions.create.call_args
|
||||
messages = call_args.kwargs.get('messages', call_args[1].get('messages', []))
|
||||
|
||||
# Verify product info was included in prompt
|
||||
user_message = messages[-1]['content']
|
||||
assert sample_product['name'] in user_message
|
||||
# Price is formatted with commas, check for the value
|
||||
assert "15,999" in user_message or "15999" in user_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_service_post(self, generator, sample_service):
|
||||
"""Test generating a service post."""
|
||||
result = await generator.generate_service_post(
|
||||
service=sample_service,
|
||||
platform="facebook"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
call_args = generator.client.chat.completions.create.call_args
|
||||
messages = call_args.kwargs.get('messages', call_args[1].get('messages', []))
|
||||
|
||||
user_message = messages[-1]['content']
|
||||
assert sample_service['name'] in user_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_thread(self, generator):
|
||||
"""Test generating a thread."""
|
||||
generator.client.chat.completions.create.return_value.choices[0].message.content = \
|
||||
"1/ Post uno\n2/ Post dos\n3/ Post tres"
|
||||
|
||||
result = await generator.generate_thread(
|
||||
topic="Cómo proteger tu contraseña",
|
||||
num_posts=3
|
||||
)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_response_suggestion(self, generator, sample_interaction):
|
||||
"""Test generating response suggestions."""
|
||||
generator.client.chat.completions.create.return_value.choices[0].message.content = \
|
||||
"1. Respuesta corta\n2. Respuesta media\n3. Respuesta larga"
|
||||
|
||||
result = await generator.generate_response_suggestion(
|
||||
interaction_content=sample_interaction['content'],
|
||||
interaction_type=sample_interaction['type']
|
||||
)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) <= 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapt_content_for_platform(self, generator):
|
||||
"""Test adapting content for different platforms."""
|
||||
original = "Este es un tip muy largo sobre seguridad informática con muchos detalles"
|
||||
|
||||
result = await generator.adapt_content_for_platform(
|
||||
content=original,
|
||||
target_platform="x"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
call_args = generator.client.chat.completions.create.call_args
|
||||
messages = call_args.kwargs.get('messages', call_args[1].get('messages', []))
|
||||
|
||||
user_message = messages[-1]['content']
|
||||
assert "280" in user_message # X character limit
|
||||
|
||||
def test_get_system_prompt(self, generator, mock_settings):
|
||||
"""Test that system prompt includes business info."""
|
||||
prompt = generator._get_system_prompt()
|
||||
|
||||
assert mock_settings.BUSINESS_NAME in prompt
|
||||
assert mock_settings.BUSINESS_LOCATION in prompt
|
||||
|
||||
def test_lazy_initialization_without_api_key(self):
|
||||
"""Test that client raises error without API key."""
|
||||
with patch('app.services.content_generator.settings') as mock:
|
||||
mock.DEEPSEEK_API_KEY = None
|
||||
|
||||
from app.services.content_generator import ContentGenerator
|
||||
gen = ContentGenerator()
|
||||
|
||||
with pytest.raises(ValueError, match="DEEPSEEK_API_KEY"):
|
||||
_ = gen.client
|
||||
|
||||
|
||||
class TestCharacterLimits:
|
||||
"""Tests for character limit handling."""
|
||||
|
||||
@pytest.mark.parametrize("platform,expected_limit", [
|
||||
("x", 280),
|
||||
("threads", 500),
|
||||
("instagram", 2200),
|
||||
("facebook", 500),
|
||||
])
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_character_limits(self, platform, expected_limit, mock_settings):
|
||||
"""Test that correct character limits are used per platform."""
|
||||
with patch('app.services.content_generator.OpenAI') as mock_openai:
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "Test"
|
||||
mock_client.chat.completions.create.return_value = mock_response
|
||||
mock_openai.return_value = mock_client
|
||||
|
||||
from app.services.content_generator import ContentGenerator
|
||||
gen = ContentGenerator()
|
||||
gen._client = mock_client
|
||||
|
||||
await gen.generate_tip_tech("test", platform)
|
||||
|
||||
call_args = mock_client.chat.completions.create.call_args
|
||||
messages = call_args.kwargs.get('messages', call_args[1].get('messages', []))
|
||||
user_message = messages[-1]['content']
|
||||
|
||||
assert str(expected_limit) in user_message
|
||||
278
tests/test_publisher_manager.py
Normal file
278
tests/test_publisher_manager.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Tests for PublisherManager service.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
|
||||
class TestPublisherManager:
|
||||
"""Tests for the PublisherManager class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_publishers(self):
|
||||
"""Create mock publishers."""
|
||||
x_publisher = MagicMock()
|
||||
x_publisher.char_limit = 280
|
||||
x_publisher.validate_content.return_value = True
|
||||
x_publisher.client = MagicMock()
|
||||
x_publisher.publish = AsyncMock(return_value=MagicMock(
|
||||
success=True, post_id="123", url="https://x.com/post/123"
|
||||
))
|
||||
|
||||
threads_publisher = MagicMock()
|
||||
threads_publisher.char_limit = 500
|
||||
threads_publisher.validate_content.return_value = True
|
||||
threads_publisher.access_token = "token"
|
||||
threads_publisher.user_id = "user123"
|
||||
threads_publisher.publish = AsyncMock(return_value=MagicMock(
|
||||
success=True, post_id="456", url="https://threads.net/post/456"
|
||||
))
|
||||
|
||||
fb_publisher = MagicMock()
|
||||
fb_publisher.char_limit = 63206
|
||||
fb_publisher.validate_content.return_value = True
|
||||
fb_publisher.access_token = "token"
|
||||
fb_publisher.page_id = "page123"
|
||||
fb_publisher.publish = AsyncMock(return_value=MagicMock(
|
||||
success=True, post_id="789"
|
||||
))
|
||||
|
||||
ig_publisher = MagicMock()
|
||||
ig_publisher.char_limit = 2200
|
||||
ig_publisher.validate_content.return_value = True
|
||||
ig_publisher.access_token = "token"
|
||||
ig_publisher.account_id = "acc123"
|
||||
ig_publisher.publish = AsyncMock(return_value=MagicMock(
|
||||
success=True, post_id="101"
|
||||
))
|
||||
|
||||
return {
|
||||
"x": x_publisher,
|
||||
"threads": threads_publisher,
|
||||
"facebook": fb_publisher,
|
||||
"instagram": ig_publisher
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self, mock_publishers):
|
||||
"""Create a PublisherManager with mocked publishers."""
|
||||
with patch('app.publishers.manager.XPublisher', return_value=mock_publishers["x"]), \
|
||||
patch('app.publishers.manager.ThreadsPublisher', return_value=mock_publishers["threads"]), \
|
||||
patch('app.publishers.manager.FacebookPublisher', return_value=mock_publishers["facebook"]), \
|
||||
patch('app.publishers.manager.InstagramPublisher', return_value=mock_publishers["instagram"]):
|
||||
|
||||
from app.publishers.manager import PublisherManager, Platform
|
||||
mgr = PublisherManager()
|
||||
# Override with mocks
|
||||
mgr._publishers = {
|
||||
Platform.X: mock_publishers["x"],
|
||||
Platform.THREADS: mock_publishers["threads"],
|
||||
Platform.FACEBOOK: mock_publishers["facebook"],
|
||||
Platform.INSTAGRAM: mock_publishers["instagram"],
|
||||
}
|
||||
return mgr
|
||||
|
||||
def test_init(self, manager):
|
||||
"""Test manager initialization."""
|
||||
assert manager._publishers is not None
|
||||
assert len(manager._publishers) == 4
|
||||
|
||||
def test_get_publisher(self, manager):
|
||||
"""Test getting a specific publisher."""
|
||||
from app.publishers.manager import Platform
|
||||
|
||||
publisher = manager.get_publisher(Platform.X)
|
||||
assert publisher is not None
|
||||
|
||||
def test_get_available_platforms(self, manager):
|
||||
"""Test getting available platforms."""
|
||||
available = manager.get_available_platforms()
|
||||
|
||||
assert isinstance(available, list)
|
||||
assert "x" in available
|
||||
assert "threads" in available
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_single_platform(self, manager):
|
||||
"""Test publishing to a single platform."""
|
||||
from app.publishers.manager import Platform
|
||||
|
||||
result = await manager.publish(
|
||||
platform=Platform.X,
|
||||
content="Test post #Testing"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.post_id == "123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_content_too_long(self, manager, mock_publishers):
|
||||
"""Test that too long content fails validation."""
|
||||
from app.publishers.manager import Platform
|
||||
|
||||
mock_publishers["x"].validate_content.return_value = False
|
||||
|
||||
result = await manager.publish(
|
||||
platform=Platform.X,
|
||||
content="x" * 300 # Exceeds 280 limit
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "límite" in result.error_message.lower() or "excede" in result.error_message.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_unsupported_platform(self, manager):
|
||||
"""Test publishing to unsupported platform."""
|
||||
result = await manager.publish(
|
||||
platform=MagicMock(value="unsupported"),
|
||||
content="Test"
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "no soportada" in result.error_message.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_to_multiple_parallel(self, manager):
|
||||
"""Test publishing to multiple platforms in parallel."""
|
||||
from app.publishers.manager import Platform
|
||||
|
||||
result = await manager.publish_to_multiple(
|
||||
platforms=[Platform.X, Platform.THREADS],
|
||||
content="Multi-platform test #Test",
|
||||
parallel=True
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert len(result.successful_platforms) >= 1
|
||||
assert "x" in result.results
|
||||
assert "threads" in result.results
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_to_multiple_sequential(self, manager):
|
||||
"""Test publishing to multiple platforms sequentially."""
|
||||
from app.publishers.manager import Platform
|
||||
|
||||
result = await manager.publish_to_multiple(
|
||||
platforms=[Platform.X, Platform.FACEBOOK],
|
||||
content="Sequential test",
|
||||
parallel=False
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_to_multiple_with_dict_content(self, manager):
|
||||
"""Test publishing with platform-specific content."""
|
||||
from app.publishers.manager import Platform
|
||||
|
||||
content = {
|
||||
"x": "Short post for X #X",
|
||||
"threads": "Longer post for Threads with more details #Threads"
|
||||
}
|
||||
|
||||
result = await manager.publish_to_multiple(
|
||||
platforms=[Platform.X, Platform.THREADS],
|
||||
content=content
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_with_image_meta_platforms(self, manager, mock_publishers):
|
||||
"""Test that Meta platforms get public image URL."""
|
||||
from app.publishers.manager import Platform
|
||||
|
||||
with patch('app.publishers.manager.image_upload') as mock_upload:
|
||||
mock_upload.upload_from_path = AsyncMock(
|
||||
return_value="https://imgbb.com/image.jpg"
|
||||
)
|
||||
|
||||
result = await manager.publish(
|
||||
platform=Platform.THREADS,
|
||||
content="Post with image",
|
||||
image_path="/local/image.jpg"
|
||||
)
|
||||
|
||||
mock_upload.upload_from_path.assert_called_once_with("/local/image.jpg")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection(self, manager, mock_publishers):
|
||||
"""Test connection testing."""
|
||||
from app.publishers.manager import Platform
|
||||
|
||||
mock_publishers["x"].client.get_me.return_value = MagicMock(
|
||||
data=MagicMock(username="testuser", name="Test", id=123)
|
||||
)
|
||||
|
||||
result = await manager.test_connection(Platform.X)
|
||||
|
||||
assert result["platform"] == "x"
|
||||
assert result["configured"] is True
|
||||
assert result["connected"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_all_connections(self, manager, mock_publishers):
|
||||
"""Test testing all connections."""
|
||||
mock_publishers["x"].client.get_me.return_value = MagicMock(
|
||||
data=MagicMock(username="test", name="Test", id=123)
|
||||
)
|
||||
|
||||
results = await manager.test_all_connections()
|
||||
|
||||
assert len(results) == 4
|
||||
assert "x" in results
|
||||
assert "threads" in results
|
||||
|
||||
|
||||
class TestMultiPublishResult:
|
||||
"""Tests for MultiPublishResult dataclass."""
|
||||
|
||||
def test_successful_platforms(self):
|
||||
"""Test getting successful platforms."""
|
||||
from app.publishers.manager import MultiPublishResult
|
||||
from app.publishers.base import PublishResult
|
||||
|
||||
results = {
|
||||
"x": PublishResult(success=True, post_id="123"),
|
||||
"threads": PublishResult(success=False, error_message="Error"),
|
||||
"facebook": PublishResult(success=True, post_id="456")
|
||||
}
|
||||
|
||||
multi = MultiPublishResult(success=True, results=results, errors=[])
|
||||
|
||||
assert set(multi.successful_platforms) == {"x", "facebook"}
|
||||
|
||||
def test_failed_platforms(self):
|
||||
"""Test getting failed platforms."""
|
||||
from app.publishers.manager import MultiPublishResult
|
||||
from app.publishers.base import PublishResult
|
||||
|
||||
results = {
|
||||
"x": PublishResult(success=True, post_id="123"),
|
||||
"threads": PublishResult(success=False, error_message="Error"),
|
||||
}
|
||||
|
||||
multi = MultiPublishResult(success=True, results=results, errors=[])
|
||||
|
||||
assert multi.failed_platforms == ["threads"]
|
||||
|
||||
|
||||
class TestPlatformEnum:
|
||||
"""Tests for Platform enum."""
|
||||
|
||||
def test_platform_values(self):
|
||||
"""Test platform enum values."""
|
||||
from app.publishers.manager import Platform
|
||||
|
||||
assert Platform.X.value == "x"
|
||||
assert Platform.THREADS.value == "threads"
|
||||
assert Platform.FACEBOOK.value == "facebook"
|
||||
assert Platform.INSTAGRAM.value == "instagram"
|
||||
|
||||
def test_platform_is_string_enum(self):
|
||||
"""Test platform enum is string."""
|
||||
from app.publishers.manager import Platform
|
||||
|
||||
assert isinstance(Platform.X, str)
|
||||
assert Platform.X == "x"
|
||||
259
tests/test_scheduler.py
Normal file
259
tests/test_scheduler.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Tests for ContentScheduler service.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class TestContentScheduler:
|
||||
"""Tests for the ContentScheduler class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session(self):
|
||||
"""Create a mock database session."""
|
||||
mock_session = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None # No existing posts
|
||||
mock_query.all.return_value = []
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_session.query.return_value = mock_query
|
||||
return mock_session
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler(self, mock_db_session):
|
||||
"""Create a ContentScheduler with mocked database."""
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
from app.services.scheduler import ContentScheduler
|
||||
return ContentScheduler()
|
||||
|
||||
def test_init(self, scheduler):
|
||||
"""Test scheduler initialization."""
|
||||
assert scheduler.posting_times is not None
|
||||
assert "x" in scheduler.posting_times
|
||||
assert "threads" in scheduler.posting_times
|
||||
|
||||
def test_get_next_available_slot_weekday(self, scheduler, mock_db_session, fixed_datetime):
|
||||
"""Test getting next slot on a weekday."""
|
||||
# Monday 10:00
|
||||
weekday = datetime(2024, 6, 17, 10, 0, 0)
|
||||
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
result = scheduler.get_next_available_slot("x", after=weekday)
|
||||
|
||||
assert result is not None
|
||||
assert result > weekday
|
||||
|
||||
def test_get_next_available_slot_weekend(self, scheduler, mock_db_session):
|
||||
"""Test getting next slot on a weekend."""
|
||||
# Saturday 10:00
|
||||
weekend = datetime(2024, 6, 15, 10, 0, 0)
|
||||
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
result = scheduler.get_next_available_slot("x", after=weekend)
|
||||
|
||||
assert result is not None
|
||||
|
||||
def test_get_next_available_slot_late_night(self, scheduler, mock_db_session):
|
||||
"""Test that late night moves to next day."""
|
||||
# 11 PM
|
||||
late_night = datetime(2024, 6, 17, 23, 0, 0)
|
||||
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
result = scheduler.get_next_available_slot("x", after=late_night)
|
||||
|
||||
# Should be next day
|
||||
assert result.date() > late_night.date()
|
||||
|
||||
def test_get_available_slots(self, scheduler, mock_db_session, fixed_datetime):
|
||||
"""Test getting all available slots."""
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
slots = scheduler.get_available_slots(
|
||||
platform="x",
|
||||
start_date=fixed_datetime,
|
||||
days=3
|
||||
)
|
||||
|
||||
assert len(slots) > 0
|
||||
for slot in slots:
|
||||
assert slot.platform == "x"
|
||||
assert slot.available is True
|
||||
|
||||
def test_schedule_post(self, scheduler, mock_db_session):
|
||||
"""Test scheduling a post."""
|
||||
mock_post = MagicMock()
|
||||
mock_post.id = 1
|
||||
mock_post.platforms = ["x"]
|
||||
mock_post.status = "draft"
|
||||
|
||||
mock_db_session.query.return_value.filter.return_value.first.return_value = mock_post
|
||||
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
result = scheduler.schedule_post(
|
||||
post_id=1,
|
||||
scheduled_at=datetime(2024, 6, 20, 12, 0, 0)
|
||||
)
|
||||
|
||||
assert result == datetime(2024, 6, 20, 12, 0, 0)
|
||||
assert mock_post.status == "scheduled"
|
||||
mock_db_session.commit.assert_called_once()
|
||||
|
||||
def test_schedule_post_auto_time(self, scheduler, mock_db_session):
|
||||
"""Test scheduling with auto-selected time."""
|
||||
mock_post = MagicMock()
|
||||
mock_post.id = 1
|
||||
mock_post.platforms = ["x"]
|
||||
|
||||
# First call returns the post, second returns None (no conflicts)
|
||||
mock_db_session.query.return_value.filter.return_value.first.side_effect = [
|
||||
mock_post, None, None, None, None, None, None
|
||||
]
|
||||
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
result = scheduler.schedule_post(post_id=1)
|
||||
|
||||
assert result is not None
|
||||
assert mock_post.scheduled_at is not None
|
||||
|
||||
def test_schedule_post_not_found(self, scheduler, mock_db_session):
|
||||
"""Test scheduling a non-existent post."""
|
||||
mock_db_session.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
scheduler.schedule_post(post_id=999)
|
||||
|
||||
def test_reschedule_post(self, scheduler, mock_db_session):
|
||||
"""Test rescheduling a post."""
|
||||
mock_post = MagicMock()
|
||||
mock_post.id = 1
|
||||
mock_post.status = "scheduled"
|
||||
|
||||
mock_db_session.query.return_value.filter.return_value.first.return_value = mock_post
|
||||
|
||||
new_time = datetime(2024, 6, 25, 14, 0, 0)
|
||||
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
result = scheduler.reschedule_post(post_id=1, new_time=new_time)
|
||||
|
||||
assert result is True
|
||||
assert mock_post.scheduled_at == new_time
|
||||
mock_db_session.commit.assert_called_once()
|
||||
|
||||
def test_reschedule_published_post_fails(self, scheduler, mock_db_session):
|
||||
"""Test that published posts cannot be rescheduled."""
|
||||
mock_post = MagicMock()
|
||||
mock_post.status = "published"
|
||||
|
||||
mock_db_session.query.return_value.filter.return_value.first.return_value = mock_post
|
||||
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
result = scheduler.reschedule_post(
|
||||
post_id=1,
|
||||
new_time=datetime(2024, 6, 25, 14, 0, 0)
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_cancel_scheduled(self, scheduler, mock_db_session):
|
||||
"""Test canceling a scheduled post."""
|
||||
mock_post = MagicMock()
|
||||
mock_post.id = 1
|
||||
mock_post.status = "scheduled"
|
||||
mock_post.scheduled_at = datetime(2024, 6, 20, 12, 0, 0)
|
||||
|
||||
mock_db_session.query.return_value.filter.return_value.first.return_value = mock_post
|
||||
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
result = scheduler.cancel_scheduled(post_id=1)
|
||||
|
||||
assert result is True
|
||||
assert mock_post.status == "draft"
|
||||
assert mock_post.scheduled_at is None
|
||||
|
||||
def test_get_calendar(self, scheduler, mock_db_session):
|
||||
"""Test getting calendar view."""
|
||||
mock_posts = [
|
||||
MagicMock(
|
||||
id=1,
|
||||
content="Test post 1",
|
||||
platforms=["x"],
|
||||
status="scheduled",
|
||||
scheduled_at=datetime(2024, 6, 17, 12, 0, 0),
|
||||
content_type="tip"
|
||||
),
|
||||
MagicMock(
|
||||
id=2,
|
||||
content="Test post 2",
|
||||
platforms=["threads"],
|
||||
status="scheduled",
|
||||
scheduled_at=datetime(2024, 6, 17, 14, 0, 0),
|
||||
content_type="product"
|
||||
)
|
||||
]
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.all.return_value = mock_posts
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
result = scheduler.get_calendar(
|
||||
start_date=datetime(2024, 6, 15),
|
||||
end_date=datetime(2024, 6, 20)
|
||||
)
|
||||
|
||||
assert "2024-06-17" in result
|
||||
assert len(result["2024-06-17"]) == 2
|
||||
|
||||
def test_auto_fill_calendar(self, scheduler, mock_db_session, fixed_datetime):
|
||||
"""Test auto-filling calendar with suggested slots."""
|
||||
with patch('app.services.scheduler.SessionLocal', return_value=mock_db_session):
|
||||
slots = scheduler.auto_fill_calendar(
|
||||
start_date=fixed_datetime,
|
||||
days=3,
|
||||
platforms=["x", "threads"]
|
||||
)
|
||||
|
||||
assert len(slots) > 0
|
||||
# Should be sorted by datetime
|
||||
for i in range(1, len(slots)):
|
||||
assert slots[i].datetime >= slots[i-1].datetime
|
||||
|
||||
|
||||
class TestOptimalTimes:
|
||||
"""Tests for optimal posting times configuration."""
|
||||
|
||||
def test_x_has_weekday_times(self):
|
||||
"""Test that X platform has weekday times defined."""
|
||||
from app.data.content_templates import OPTIMAL_POSTING_TIMES
|
||||
|
||||
assert "x" in OPTIMAL_POSTING_TIMES
|
||||
assert "weekday" in OPTIMAL_POSTING_TIMES["x"]
|
||||
assert len(OPTIMAL_POSTING_TIMES["x"]["weekday"]) > 0
|
||||
|
||||
def test_all_platforms_have_times(self):
|
||||
"""Test all platforms have posting times."""
|
||||
from app.data.content_templates import OPTIMAL_POSTING_TIMES
|
||||
|
||||
expected_platforms = ["x", "threads", "instagram", "facebook"]
|
||||
|
||||
for platform in expected_platforms:
|
||||
assert platform in OPTIMAL_POSTING_TIMES
|
||||
assert "weekday" in OPTIMAL_POSTING_TIMES[platform]
|
||||
assert "weekend" in OPTIMAL_POSTING_TIMES[platform]
|
||||
|
||||
def test_time_format(self):
|
||||
"""Test that times are in correct HH:MM format."""
|
||||
from app.data.content_templates import OPTIMAL_POSTING_TIMES
|
||||
|
||||
import re
|
||||
time_pattern = re.compile(r'^([01]?[0-9]|2[0-3]):[0-5][0-9]$')
|
||||
|
||||
for platform, times in OPTIMAL_POSTING_TIMES.items():
|
||||
for day_type in ["weekday", "weekend"]:
|
||||
for time_str in times.get(day_type, []):
|
||||
assert time_pattern.match(time_str), f"Invalid time format: {time_str}"
|
||||
Reference in New Issue
Block a user