# Quickstart — index-relay MVP

**Feature**: 001-gsc-sitemap-coverage
**Date**: 2026-08-10

Guía para levantar el servicio y validar que la funcionalidad descrita en [spec.md](./spec.md)
funciona de punta a punta. El detalle de las entidades está en [data-model.md](./data-model.md) y
el de la API en [contracts/openapi.yaml](./contracts/openapi.yaml).

> Los pasos del lado de Google **no** se hacen a mano antes de empezar: los guía la propia
> plataforma (US1). Se describen acá sólo para saber qué se está validando.

---

## 1. Prerrequisitos

- **uv** y Python 3.12 o superior
- **Node 22** o superior
- Docker y Docker Compose, sólo para levantar PostgreSQL y Redis
- Una cuenta de Google con al menos una propiedad verificada en Search Console

> El proyecto arranca sin contenedores: si no hay PostgreSQL declarada, la configuración de
> desarrollo cae en SQLite. PostgreSQL sigue siendo el almacén definitivo y producción la exige.

Lo que la plataforma va a pedir, y va a explicar cómo obtener:

1. Un proyecto en Google Cloud, para tomar su identificador.
2. La API de Search Console habilitada en ese proyecto.
3. Una **cuenta de servicio** con su archivo de clave en formato JSON.
4. Esa cuenta de servicio agregada como **propietario** en cada propiedad de Search Console.

> Una API key de Google **no** sirve: no da acceso a los datos privados de una propiedad. Y el
> permiso en Search Console debe ser de propietario; con permisos menores la inspección de URLs
> falla con `PERMISSION_DENIED`.

---

## 2. Configuración

```bash
cp .env.example .env
```

Las dos claves se generan, no se inventan:

```bash
uv run python -c "import secrets; print(secrets.token_urlsafe(50))"                    # SECRET_KEY
uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"  # SETTINGS_ENCRYPTION_KEY
```

Variables mínimas:

| Variable | Ejemplo | Notas |
|---|---|---|
| `SECRET_KEY` | *(generada)* | |
| `SETTINGS_ENCRYPTION_KEY` | *(generada)* | Cifra las claves de cuenta de servicio guardadas |
| `DATABASE_URL` | `postgres://relay:relay@localhost:5432/relay` | Si se omite, SQLite |
| `REDIS_URL` | `redis://localhost:6379/0` | Broker de Celery |
| `DISPLAY_TIMEZONE` | `America/Argentina/Buenos_Aires` | Zona de presentación (FR-065) |
| `DEFAULT_DAILY_INSPECTION_BUDGET` | `2000` | Límite publicado por Google |
| `DEFAULT_MANUAL_RESERVE` | `200` | Reservado a inspecciones manuales |
| `DJANGO_VITE_DEV_MODE` | `true` | `true`: los assets los sirve Vite. `false`: manifiesto compilado |
| `BILLING_ENABLED` | `false` | Capacidad apagada (principio VI) |
| `TXT_VERIFICATION_ENABLED` | `false` | Capacidad apagada (principio VI) |

> `SETTINGS_ENCRYPTION_KEY` es un secreto de despliegue de primer nivel. Perderla obliga a volver
> a cargar todas las credenciales; filtrarla las compromete todas. Su rotación se documenta en
> `docs/operations.md`.

---

## 3. Levantar el entorno

```bash
uv sync                                    # entorno e instalación de Python
npm install                                # dependencias del navegador
uv run python manage.py migrate
uv run python manage.py createsuperuser
```

En el entorno de desarrollo de esta máquina ya existe una cuenta cargada:
`prueba@ejemplo.test` con contraseña `prueba-local-1234`, y un dominio de ejemplo con ciento
veinte URLs en distintos estados de cobertura. Es una base local en SQLite, sin ningún valor
fuera de este entorno: `db.sqlite3` no se versiona y se rehace corriendo `migrate` de nuevo.

La clave de la credencial de Google que aparece cargada **no es real**: su bloque PEM es un texto
inventado. Sirve para ver las pantallas, no para consultar Google.

Y con dos terminales, para desarrollo con recarga en caliente:

```bash
npm run dev                                # con DJANGO_VITE_DEV_MODE=true
uv run python manage.py runserver
```

Se espera ver en el arranque:

```text
index-relay iniciando — BILLING_ENABLED=False TXT_VERIFICATION_ENABLED=False DISPLAY_TIMEZONE=…
```

Si falta `SETTINGS_ENCRYPTION_KEY`, el arranque **debe** fallar con un mensaje explícito en vez de
continuar degradado.

Para comprobar el modo de producción de los assets, que no usa el servidor de Vite:

```bash
npm run build                              # deja el manifiesto en static/dist/.vite/
# con DJANGO_VITE_DEV_MODE=false
uv run python manage.py runserver
```

La aplicación debe cargar con los assets compilados. Si depende del servidor de Vite para
arrancar, la configuración de `django-vite` está mal (T129).

> Con `DJANGO_VITE_DEV_MODE=false`, **hay que reiniciar Django después de cada `npm run build`**:
> el manifiesto se lee una sola vez al arrancar, así que tras recompilar el servidor sigue
> pidiendo los archivos con los identificadores anteriores y devuelve 404. En modo de desarrollo
> no pasa, porque los assets los sirve Vite.

**Comprobación mínima del cableado**: `GET /login` responde `200`, su HTML contiene
`data-page="app"` con `"component": "Login"`, y la hoja de estilos referenciada se sirve.

Pero eso **no alcanza**: el servidor manda el contenedor de montaje vacío y React lo llena en el
cliente, así que una petición HTTP no distingue entre una página que funciona y una que queda en
blanco. La comprobación real es **abrir el navegador** y confirmar tres cosas: que el título y el
botón aparecen, que el botón tiene los estilos de la biblioteca de componentes, y que la consola
no reporta ningún recurso faltante. Los fallos de rutas de assets sólo se manifiestan ahí.

---

## 4. Validación de la Historia 1 — Conectar Google guiado

Entrar a `http://localhost:8000/settings/` con una cuenta sin credencial cargada.

**Esperado**: estado "sin configurar", la guía paso a paso visible y las funciones de dominio
deshabilitadas con un enlace a esta pantalla.

**Comprobaciones obligatorias de la guía**:

- El paso de la clave muestra un ejemplo anonimizado del archivo con `type`, `project_id`,
  `private_key_id` y `client_email` resaltados, y advierte que una API key no sirve (FR-055).
- Cada paso indica su objetivo **y** la ruta concreta dentro de las pantallas de Google (FR-056).

```bash
# Equivalente por API
curl -X POST http://localhost:8000/api/v1/credentials \
  -H "Authorization: Api-Key $RELAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"project_id":"mi-proyecto-483920","module_code":"SEARCH_CONSOLE","key_file":{...}}'
```

**Esperado**: `201` con `status: "UNVERIFIED"`, `client_email` y `key_fingerprint` presentes, y
**sin** ningún campo que contenga el material de la clave.

**Casos negativos obligatorios**, cada uno con su propio mensaje:

| Se envía | Debe responder |
|---|---|
| Una API key de Google | `422` nombrando que se esperaba una cuenta de servicio |
| Credencial de aplicación de escritorio | `422` nombrando el tipo encontrado |
| JSON sin `client_email` | `422` nombrando el campo faltante |
| Identificador de proyecto con formato inválido | `400` **sin** haber llamado a Google |

```bash
curl -X POST http://localhost:8000/api/v1/credentials/$CRED_ID/verify \
  -H "Authorization: Api-Key $RELAY_KEY"
```

**Esperado con todo bien**: `status: "VERIFIED"` y la lista de propiedades accesibles.

**Esperado con la API sin habilitar**: `error_code: "API_NOT_ENABLED"` con el enlace para
habilitarla. Si este caso devuelve el mismo error que una clave revocada, FR-010 no está cumplido.

**Reemplazo**: cargar una credencial nueva y confirmar que los dominios existentes siguen
asociados y operativos (FR-012).

---

## 5. Validación de la Historia 2 — Ver la cobertura real

```bash
uv run python manage.py create_api_key --name "local"
export RELAY_KEY="…"

curl -X POST http://localhost:8000/api/v1/domains \
  -H "Authorization: Api-Key $RELAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"hostname":"ejemplo.com","property_type":"DOMAIN","property_uri":"sc-domain:ejemplo.com"}'
```

**Esperado**: `201`, `access_state: "AWAITING_ACCESS"` y el `service_account_email` a autorizar.

```bash
curl -X POST http://localhost:8000/api/v1/domains/$DOMAIN_ID/check-access \
  -H "Authorization: Api-Key $RELAY_KEY"
```

**Esperado**: `access_state: "OPERATIONAL"` con `checked_at` presente.

**Caso negativo obligatorio**: sobre una propiedad donde la cuenta de servicio no fue autorizada,
debe devolver `PERMISSION_DENIED`, distinto de `PROPERTY_NOT_FOUND`.

```bash
curl "http://localhost:8000/api/v1/domains/$DOMAIN_ID/coverage?state=UNKNOWN" \
  -H "Authorization: Api-Key $RELAY_KEY"
```

**Esperado antes de inspeccionar**: todas las URLs en `UNKNOWN` con `fetched_at` nulo. Ninguna
puede aparecer como indexada o no indexada sin haber sido consultada (FR-029).

---

## 6. Validación de la Historia 3 — Sincronizar sitemaps

```bash
curl -X POST http://localhost:8000/api/v1/domains/$DOMAIN_ID/sitemaps \
  -H "Authorization: Api-Key $RELAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"location":"https://ejemplo.com/sitemap_index.xml"}'

# Lo que llamaría el pipeline de despliegue
curl -X POST http://localhost:8000/api/v1/domains/$DOMAIN_ID/sync \
  -H "Authorization: Api-Key $RELAY_KEY" \
  -H "Idempotency-Key: deploy-$(git rev-parse --short HEAD)"
```

**Esperado**: `202` con un lote `SITEMAP_SYNC` cuyo resumen distingue los sitemaps enviados de los
omitidos por no haber cambiado.

**Prueba de idempotencia**: repetir con idéntica `Idempotency-Key` devuelve el mismo lote, sin
crear uno nuevo ni reenviar sitemaps.

**Caso negativo**: un sitemap con URLs de otro dominio devuelve `422` con el motivo y no reporta
ningún envío exitoso.

---

## 7. Validación de la Historia 4 — Recorrido guiado

Entrar con una cuenta nueva.

**Esperado**: se ofrece el recorrido y también la opción de omitirlo.

Recorrer los siete pasos —proyecto, habilitar API, clave de cuenta de servicio, autorizar la
propiedad, dar de alta el dominio, registrar el sitemap, primer lote— y verificar:

- Un paso **no** avanza si su requisito no está cumplido, e indica qué falta y dónde resolverlo.
- Cerrar sesión a mitad de camino y volver retoma en el mismo paso, con lo cumplido marcado.
- Retomar **no** duplica proyecto, credencial, dominio, sitemap ni lote (SC-009).
- Omitirlo no restringe nada: los formularios completos ofrecen las mismas operaciones.
- Completado, no se vuelve a ofrecer.

---

## 8. Validación de la Historia 5 — Lotes y avisos

```bash
uv run python manage.py run_daily_cycle --domain $DOMAIN_ID

curl "http://localhost:8000/api/v1/domains/$DOMAIN_ID/quota" \
  -H "Authorization: Api-Key $RELAY_KEY"
```

**Esperado**: `used_automatic` crece durante el ciclo y nunca supera
`limit_total - manual_reserve`. El cupo manual queda disponible aunque el automático se agote.

**Esperado del lote**: si el cupo se agota antes de terminar, queda en `PARTIAL`. Un lote
interrumpido por cuota que aparezca como `COMPLETED` es un fallo de FR-032.

**Correo**: en desarrollo sale por la consola del worker. Un único correo de resumen al terminar;
reejecutar la tarea no debe producir un segundo.

---

## 9. Validación de la Historia 6 — Panel interno

En `http://localhost:8000/admin/` verificar:

- Se ven cuentas, dominios, consumo del período y estado de las credenciales, **sin** exponer
  jamás el material de la clave.
- Revocar el acceso de un dominio lo deja en `ACCESS_REVOKED` y detiene su monitoreo.
- Cerrar sesiones obliga a volver a autenticarse.
- Cada acción queda registrada con autor, fecha y motivo.

---

## 10. Comprobación de las capacidades apagadas

```bash
# El esquema publicado no debe mencionar planes, precios ni verificación TXT
curl -s http://localhost:8000/api/schema/ | grep -iE "plan|price|billing|txt_" && echo "FALLO"
```

**Esperado**: sin coincidencias, lo mismo en plantillas de correo y pantallas.

**Esperado en la base**: las tablas de `billing` existen, hay un único plan `unlimited`
predeterminado, y `ConsumptionRecord` acumula consumo aunque no se cobre (SC-015).

---

## 11. Suite automatizada

```bash
uv run pytest
```

La suite **no** accede a la red: las respuestas de Google se reproducen desde
`tests/fixtures/gsc/`.

| Verifica | Origen |
|---|---|
| Ninguna llamada a Google sin reserva de cupo concedida | Principio II |
| Un `coverageState` desconocido nunca se traduce a `INDEXED` | Principio I, R4 |
| Ningún `CoverageRecord` sin `fetched_at` | Invariante 1 |
| El material de la clave no aparece en respuestas, plantillas ni registros | Principio III, SC-012 |
| Cifrado y descifrado de ida y vuelta, y rotación de la clave | R14 |
| Los cinco fallos de credencial se distinguen entre sí | FR-010, SC-002 |
| Retomar el recorrido guiado no duplica objetos | FR-019, SC-009 |
| Las tareas asíncronas son idempotentes | Flujo de trabajo |
| Los flags apagados son falsos por defecto y su punto de decisión se invoca igual | Principio VI |
| Toda operación de la interfaz tiene endpoint equivalente | Principio IV, R9 |
| El esquema generado coincide con `contracts/openapi.yaml` | Principio IV, R11 |
