diff --git a/ai/tasks/done/TASK-205-fix-annual-ranking-generator-postgres-default.md b/ai/tasks/done/TASK-205-fix-annual-ranking-generator-postgres-default.md new file mode 100644 index 0000000..c7770c9 --- /dev/null +++ b/ai/tasks/done/TASK-205-fix-annual-ranking-generator-postgres-default.md @@ -0,0 +1,185 @@ +--- +id: TASK-205 +title: Fix annual ranking generator PostgreSQL default +status: done +type: backend +team: Backend Senior +supporting_teams: + - Arquitecto de Base de Datos + - Arquitecto Python +roadmap_item: foundation +priority: high +--- + +# TASK-205 - Fix annual ranking generator PostgreSQL default + +## Goal + +Corregir el CLI anual de `backend/app/rcon_annual_rankings.py` para que use PostgreSQL por defecto cuando `HLL_BACKEND_DATABASE_URL` esta configurado, alineando la generacion operativa con el read path de `/api/ranking?timeframe=annual...`. + +## Context + +Produccion ya confirma que la API anual consulta el read model correcto pero no encuentra snapshot, mientras el comando manual anual generaba en SQLite local. La causa raiz validada era que `_main()` llamaba a `generate_annual_ranking_snapshot(..., db_path=get_storage_path())`, lo que forzaba `explicit_sqlite_path != None` y desactivaba `use_postgres_rcon_storage(...)` aunque `get_database_url()` existiera. + +Este comportamiento ya se habia corregido en weekly/monthly durante `TASK-195`, por lo que esta task replica el mismo criterio operacional en el flujo anual sin tocar frontend, endpoints publicos ni la logica anual fuera de la seleccion de storage por defecto. + +Preserve the current product identity: Spanish-speaking HLL Vietnam community, military/Vietnam/tactical/sober visual direction and controlled repository evolution. + +## Steps + +1. Revisar el flujo anual actual y comparar `_main()` con el patron corregido en weekly/monthly. +2. Cambiar el CLI anual para que use `db_path=None` por defecto y PostgreSQL cuando el entorno lo soporte. +3. Mantener SQLite solo como override explicito mediante `--sqlite-path`. +4. Preservar la salida JSON del CLI y el contrato publico del endpoint anual. +5. Extender la regresion de `scripts/run-stats-validation.ps1` para cubrir la ruta anual. +6. Documentar causa raiz, comportamiento anterior, comportamiento nuevo y validacion operacional. + +## Files to Read First + +- `AGENTS.md` +- `ai/repo-context.md` +- `ai/architecture-index.md` +- `backend/app/rcon_annual_rankings.py` +- `backend/app/config.py` +- `backend/app/postgres_rcon_storage.py` +- `backend/app/rcon_historical_leaderboards.py` +- `scripts/run-stats-validation.ps1` +- `docs/ranking-snapshot-read-model-plan.md` +- `docs/annual-ranking-snapshot-runbook.md` +- `docs/annual-ranking-snapshot-schema-plan.md` +- `ai/tasks/done/TASK-195-fix-ranking-snapshot-generator-postgres-default.md` +- `ai/tasks/done/TASK-196-fix-ranking-snapshot-cli-json-serialization.md` + +## Expected Files to Modify + +- `backend/app/rcon_annual_rankings.py` +- `scripts/run-stats-validation.ps1` +- `docs/annual-ranking-snapshot-runbook.md` +- `ai/tasks/done/TASK-205-fix-annual-ranking-generator-postgres-default.md` + +## Constraints + +- Keep the change minimal. +- No ejecutar `ai-platform run`. +- No modificar frontend ni diseno. +- No cambiar el contrato publico de `/api/ranking?timeframe=annual...`. +- No cambiar la logica de calculo anual salvo lo necesario para usar PostgreSQL por defecto. +- No tocar weekly/monthly salvo reutilizacion minima de patron o helper ya existente. +- No tocar `player_search_index`, `player_period_stats` ni el runner. +- No tocar `frontend/assets/img/weapons/` ni SVGs de armas. +- No reactivar Elo/MMR. +- No reintroducir Comunidad Hispana #03. +- No mezclar esta task con los cambios visuales pendientes de `TASK-204`. + +## Validation + +Before completing the task ensure: + +- `python -m py_compile backend/app/rcon_annual_rankings.py` +- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- validacion local o por import demuestra que `_main()` ya no pasa `get_storage_path()` por defecto +- validacion local demuestra que `generate_annual_ranking_snapshot(..., db_path=None)` usa PostgreSQL cuando `HLL_BACKEND_DATABASE_URL` esta configurado +- la salida JSON del CLI anual sigue serializando correctamente +- `git diff --name-only` matches the expected scope + +## Outcome + +Causa raiz: + +- `backend/app/rcon_annual_rankings.py` llamaba a `generate_annual_ranking_snapshot(..., db_path=get_storage_path())` desde `_main()` +- eso forzaba `explicit_sqlite_path != None` +- `use_postgres_rcon_storage(...)` devolvia `False` incluso con `HLL_BACKEND_DATABASE_URL` configurado +- el CLI anual generaba snapshots en SQLite mientras `/api/ranking?timeframe=annual...` leia PostgreSQL + +Comportamiento anterior: + +- `python -m app.rcon_annual_rankings generate ...` +- usaba SQLite por defecto +- podia dejar `rcon_annual_ranking_snapshots` y `rcon_annual_ranking_snapshot_items` vacias en PostgreSQL aunque el operador hubiera ejecutado el comando con exito + +Comportamiento nuevo: + +- el CLI anual usa PostgreSQL por defecto cuando `HLL_BACKEND_DATABASE_URL` esta configurado +- SQLite queda disponible solo mediante `--sqlite-path` +- la salida JSON del CLI anual sigue serializando `datetime` y `date` correctamente + +Archivos modificados: + +- `backend/app/rcon_annual_rankings.py` +- `scripts/run-stats-validation.ps1` +- `docs/annual-ranking-snapshot-runbook.md` +- `ai/tasks/done/TASK-205-fix-annual-ranking-generator-postgres-default.md` + +Cambio aplicado en codigo: + +- `backend/app/rcon_annual_rankings.py` + - elimina el default que forzaba `get_storage_path()` en `_main()` + - anade `--sqlite-path ` como override explicito + - pasa `db_path=args.sqlite_path` al generador anual + - anade serializacion JSON segura para `datetime` y `date` + +Regresion anadida: + +- `scripts/run-stats-validation.ps1` + - valida que el CLI anual use `db_path=None` por defecto + - valida que `--sqlite-path` siga funcionando + - valida por import que `generate_annual_ranking_snapshot(..., db_path=None)` seleccione PostgreSQL cuando `HLL_BACKEND_DATABASE_URL` esta configurado + +Comando operativo final: + +- local / contenedor backend: + - `python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing` + +Comando Docker final recomendado: + +- `docker compose exec backend python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing` + +Validaciones ejecutadas: + +- `python -m py_compile backend/app/rcon_annual_rankings.py` +- validacion local por import de `_main()`: + - confirma `db_path=None` por defecto + - confirma serializacion JSON correcta de `datetime` y `date` +- validacion local por import de `generate_annual_ranking_snapshot(..., db_path=None)`: + - confirma seleccion de PostgreSQL cuando `HLL_BACKEND_DATABASE_URL` esta configurado +- ejecucion manual real del CLI: + - `python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing --sqlite-path data/hll_vietnam_dev.sqlite3` +- validacion local del endpoint anual por import: + - `/api/ranking?timeframe=annual&year=&server_id=all&metric=kills&limit=20` + - mantiene `status=ok`, `timeframe=annual`, `metric=kills`, `snapshot_status`, `items`, `generated_at`, `window_start` y `window_end` + +Resultado de scripts globales: + +- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1` + - falla antes de la nueva regresion anual por una asercion de frontend preexistente: + - `Stats page no longer exposes backend state chip.` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` + - falla por arrastrar el mismo fallo previo de `run-stats-validation.ps1` + +Nota de alcance sobre esos fallos: + +- la causa no pertenece a esta task backend +- viene del arbol local ya modificado en frontend y de la validacion global que aun espera `id="stats-backend-state"` +- no se corrigio aqui porque el usuario pidio no modificar frontend ni mezclar esta task con `TASK-204` + +Como validar en produccion: + +1. Ejecutar: + - `docker compose exec backend python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing` +2. Consultar PostgreSQL: + - `rcon_annual_ranking_snapshots` + - `rcon_annual_ranking_snapshot_items` +3. Confirmar que ya no se escriben filas nuevas en SQLite por defecto. +4. Llamar a: + - `/api/ranking?timeframe=annual&metric=kills&limit=30&year=2026` +5. Confirmar: + - `snapshot_status=ready` + - `items` no vacio cuando exista cobertura anual + - `read_model = rcon-annual-ranking-snapshot` + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/backend/app/rcon_annual_rankings.py b/backend/app/rcon_annual_rankings.py index 21d4100..b2d31b5 100644 --- a/backend/app/rcon_annual_rankings.py +++ b/backend/app/rcon_annual_rankings.py @@ -5,10 +5,10 @@ from __future__ import annotations import argparse import json from contextlib import closing -from datetime import datetime, timezone +from datetime import date, datetime, timezone from pathlib import Path -from .config import get_storage_path, use_postgres_rcon_storage +from .config import use_postgres_rcon_storage from .historical_storage import ALL_SERVERS_SLUG from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer @@ -547,6 +547,14 @@ def _build_scope_sql(server_key: str, *, table_alias: str = "matches") -> tuple[ ) +def _json_default(value: object) -> str: + if isinstance(value, datetime): + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if isinstance(value, date): + return value.isoformat() + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") + + def _main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Generate annual ranking snapshots.") subparsers = parser.add_subparsers(dest="command") @@ -555,6 +563,12 @@ def _main(argv: list[str] | None = None) -> int: generate_parser.add_argument("--server-key", default=None) generate_parser.add_argument("--metric", default="kills") generate_parser.add_argument("--limit", type=int, default=20) + generate_parser.add_argument( + "--sqlite-path", + type=Path, + default=None, + help="explicit local SQLite override; default operational mode uses PostgreSQL when configured", + ) generate_parser.add_argument("--replace-existing", action="store_true", default=True) parser.set_defaults(command="generate") args = parser.parse_args(argv) @@ -566,9 +580,16 @@ def _main(argv: list[str] | None = None) -> int: metric=args.metric, limit=args.limit, replace_existing=args.replace_existing, - db_path=get_storage_path(), + db_path=args.sqlite_path, + ) + print( + json.dumps( + {"status": "ok", "data": payload}, + ensure_ascii=True, + indent=2, + default=_json_default, + ) ) - print(json.dumps({"status": "ok", "data": payload}, ensure_ascii=True, indent=2)) return 0 parser.print_help() diff --git a/docs/annual-ranking-snapshot-runbook.md b/docs/annual-ranking-snapshot-runbook.md index e305957..607f9d2 100644 --- a/docs/annual-ranking-snapshot-runbook.md +++ b/docs/annual-ranking-snapshot-runbook.md @@ -2,16 +2,16 @@ Objetivo: operar el ranking anual top 20 de `Stats` de forma reproducible, usando snapshots precomputados para no recalcular anualmente por cada request. -## 1) Propósito del snapshot anual top 20 +## 1) Proposito del snapshot anual top 20 El snapshot anual proporciona la tabla de posiciones de jugadores para el bloque de ranking anual de `Stats` con impacto bajo en latencia y costo de consulta. -Objetivos del diseño: +Objetivos del diseno: -- entregar `top 20` de la temporada por métrica (V1: `kills`); -- consumir un resultado estable desde API pública sin recalcular toda la historia anual en cada request; -- permitir validación operativa clara de si el ranking está disponible o no; -- soportar regeneración controlada por proceso de mantenimiento. +- entregar `top 20` de la temporada por metrica (V1: `kills`); +- consumir un resultado estable desde API publica sin recalcular toda la historia anual en cada request; +- permitir validacion operativa clara de si el ranking esta disponible o no; +- soportar regeneracion controlada por proceso de mantenimiento. ## 2) Fuente de datos @@ -24,12 +24,12 @@ Filtro principal: - `matches.source_basis = admin-log-match-ended` -Reglas clave de cálculo: +Reglas clave de calculo: -- ventanas por año (`YYYY-01-01T00:00:00Z` a `YYYY+1-01-01T00:00:00Z`); +- ventanas por ano (`YYYY-01-01T00:00:00Z` a `YYYY+1-01-01T00:00:00Z`); - acumulado de `kills`, `deaths`, `teamkills` por `player_id`; - orden por `metric_value` desc, `matches_considered` desc, `player_name` asc; -- sólo posiciones con actividad válida y `player_name` no vacío. +- solo posiciones con actividad valida y `player_name` no vacio. ## 3) Endpoint consumidor @@ -44,28 +44,42 @@ En V1: ## 4) Como generar snapshot anual -La generación se hace fuera de la API (job/comando) para precomputar: +La generacion se hace fuera de la API (job/comando) para precomputar: -1. Definir año objetivo `year`. -2. Definir alcance `server_id` (`all` para global o servidor específico). -3. Ejecutar generador de snapshot anual con la configuración necesaria. +1. Definir ano objetivo `year`. +2. Definir alcance `server_id` (`all` para global o servidor especifico). +3. Ejecutar generador de snapshot anual con la configuracion necesaria. 4. Generador: - elimina snapshot previo del mismo `(year, server_id, metric)` si existe; - recalcula top-k desde datos materializados RCON; - guarda cabecera + items del ranking en tablas snapshots; - persiste metadatos (`generated_at`, ventana y conteo fuente). -### Comando recomendado (si el entorno lo habilita) +### Comando recomendado ```bash -python -m app.rcon_annual_rankings generate --year 2026 --server-key all --metric kills --limit 20 +python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing ``` Notas: -- El comando puede fallar si la capa de datos local no está inicializada; -- en entornos manuales, validar entorno y ruta de DB antes de ejecutar; -- si no hay datos, se puede generar un snapshot vacío (ready con `items=[]`). +- Cuando `HLL_BACKEND_DATABASE_URL` esta configurado, el comando usa PostgreSQL por defecto. +- SQLite queda solo como override explicito mediante `--sqlite-path `. +- El comando puede fallar si la capa de datos local no esta inicializada. +- En entornos manuales, validar entorno y storage objetivo antes de ejecutar. +- Si no hay datos, se puede generar un snapshot vacio (`ready` con `items=[]`). + +### Override local SQLite + +```bash +python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing --sqlite-path backend/data/hll_vietnam_dev.sqlite3 +``` + +### Comando Docker recomendado + +```bash +docker compose exec backend python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing +``` ## 5) Como regenerarlo @@ -77,19 +91,19 @@ Regenerar cuando: Procedimiento: -1. Ejecutar nuevamente el generador con el mismo `year` y `server_id`; -2. Aceptar reemplazo seguro del snapshot existente; +1. Ejecutar nuevamente el generador con el mismo `year` y `server_id`. +2. Aceptar reemplazo seguro del snapshot existente. 3. Validar que el snapshot nuevo refleje el recuento de partidas fuente actualizado. -Recomendación: +Recomendacion: -- programar recálculo en ventanas de mantenimiento (idealmente cierre anual o rutina periódica definida por operaciones). +- programar recalculo en ventanas de mantenimiento (idealmente cierre anual o rutina periodica definida por operaciones). ## 6) Como validar que existe un snapshot -Verificación local (reconstruir estado esperable): +Verificacion local: -1. Consultar API de ranking anual del año objetivo. +1. Consultar API de ranking anual del ano objetivo. 2. Revisar `snapshot_status` en respuesta: - `ready`: existe snapshot; - `missing`: no existe snapshot. @@ -112,37 +126,36 @@ Usar llamadas directas a backend para validar estado y contenido: Checklist: -- HTTP 200 esperado para parámetros válidos de V1. +- HTTP 200 esperado para parametros validos de V1. - `status` debe ser `"ok"` con estructura de data consistente. -- Para snapshots `ready`, `effective_limit` puede ser menor que `requested_limit` - cuando el snapshot fue generado con un limite menor o contiene menos filas. -- en V1 con `metric` no soportada, esperar error de request (400) sin recomputar ranking. +- Para snapshots `ready`, `effective_limit` puede ser menor que `requested_limit` cuando el snapshot fue generado con un limite menor o contiene menos filas. +- En V1 con `metric` no soportada, esperar error de request (400) sin recomputar ranking. ## 8) Como validar desde frontend Stats Desde `frontend/stats.html`: -1. Abrir la pestaña `Stats`. +1. Abrir la pestana `Stats`. 2. Ejecutar consulta anual usando `year`. 3. Confirmar: - estado de mensaje de carga/success/empty/error; - render de filas cuando haya items; - - texto explícito cuando el snapshot está `missing`; - - texto explícito cuando el snapshot existe pero no tiene items. -4. Confirmar que no rompe bloque semanal/mensual ni búsqueda cuando el anual no está disponible. -5. Si endpoint retorna métricas inválidas, validar estado de warning en UI y que no cambia el resto del flujo. + - texto explicito cuando el snapshot esta `missing`; + - texto explicito cuando el snapshot existe pero no tiene items. +4. Confirmar que no rompe bloque semanal/mensual ni busqueda cuando el anual no esta disponible. +5. Si endpoint retorna metricas invalidas, validar estado de warning en UI y que no cambia el resto del flujo. ## 9) Casos esperados ### 9.1 `snapshot_status=ready` con items -Respuesta típica: +Respuesta tipica: - `status: "ok"` - `data.snapshot_status: "ready"` - `data.items` con ranking ordenado. -Debe mostrarse top 20 con campos mínimos: +Debe mostrarse top 20 con campos minimos: - `ranking_position` - `player_name` @@ -155,55 +168,55 @@ Debe mostrarse top 20 con campos mínimos: ### 9.2 `snapshot_status=ready` sin items -Respuesta típica: +Respuesta tipica: - `snapshot_status: "ready"` - `items: []` - `generated_at` puede estar presente -Debe renderizar estado "snapshot ready vacío" y no tratarlo como error de sistema. +Debe renderizar estado "snapshot ready vacio" y no tratarlo como error de sistema. ### 9.3 `snapshot_status=missing` -Respuesta típica: +Respuesta tipica: - `snapshot_status: "missing"` - `items: []` -Debe mostrar estado informativo claro de que el ranking no fue generado aún. +Debe mostrar estado informativo claro de que el ranking no fue generado aun. -### 9.4 Métrica no soportada +### 9.4 Metrica no soportada -Respuesta típica: +Respuesta tipica: -- error request (400) con mensaje de métrica inválida/no soportada. +- error request (400) con mensaje de metrica invalida/no soportada. La UI/backend no debe intentar recalcular ni degradar a comportamiento inesperado. ## 10) Advertencias operativas -- No reactivar Elo/MMR ni lógica dependiente en este bloque. +- No reactivar Elo/MMR ni logica dependiente en este bloque. - No reintroducir `Comunidad Hispana #03` como alcance normal. -- No usar scoreboard público como fuente primaria si RCON materializado está disponible. -- No recalcular ranking anual completo en cada request público; siempre leer snapshot. +- No usar scoreboard publico como fuente primaria si RCON materializado esta disponible. +- No recalcular ranking anual completo en cada request publico; siempre leer snapshot. - No tocar `frontend/assets/js/partida-actual.js` ni `frontend/assets/img/clans/bxb.png` en este runbook. -## Checklist de operación +## Checklist de operacion -- [ ] Definir año/servidor/limite. -- [ ] Ejecutar generación o regeneración. -- [ ] Confirmar respuesta de API por año objetivo. +- [ ] Definir ano/servidor/limite. +- [ ] Ejecutar generacion o regeneracion. +- [ ] Confirmar respuesta de API por ano objetivo. - [ ] Confirmar estado en UI de Stats. -- [ ] Registrar fecha/hora de generación y responsable. +- [ ] Registrar fecha/hora de generacion y responsable. - [ ] Si faltan datos esperados, revisar pipeline RCON materializado. -## Validación de la task +## Validacion de la task -- `docs/annual-ranking-snapshot-runbook.md` creado. -- Cambios esperados únicamente de documentación. -- No se aplican tests automáticos (task de documentación-only); se documenta explícitamente. +- `docs/annual-ranking-snapshot-runbook.md` actualizado. +- Cambios esperados unicamente de documentacion. +- No se aplican tests automaticos en este runbook; la validacion real se ejecuta en la task de backend correspondiente. -## Próximos pasos recomendados +## Proximos pasos recomendados -- Si el bloque muestra estados esperados y refrescos, conectar este runbook con operación de mantenimiento programada. -- Mantener la misma política de fuentes en futuros reportes o automatizaciones de jobs. +- Si el bloque muestra estados esperados y refrescos, conectar este runbook con operacion de mantenimiento programada. +- Mantener la misma politica de fuentes en futuros reportes o automatizaciones de jobs. diff --git a/scripts/run-stats-validation.ps1 b/scripts/run-stats-validation.ps1 index 4e2836b..7be695b 100644 --- a/scripts/run-stats-validation.ps1 +++ b/scripts/run-stats-validation.ps1 @@ -128,9 +128,11 @@ from app.routes import resolve_get_payload from app.config import use_postgres_rcon_storage import app.postgres_rcon_storage as postgres_rcon_storage import app.historical_runner as historical_runner +import app.rcon_annual_rankings as annual_rankings import app.rcon_historical_leaderboards as ranking_leaderboards import app.rcon_historical_player_stats as player_search_stats +generate_annual_ranking_snapshot = annual_rankings.generate_annual_ranking_snapshot initialize_ranking_snapshot_storage = ranking_leaderboards.initialize_ranking_snapshot_storage generate_ranking_snapshot = ranking_leaderboards.generate_ranking_snapshot get_latest_ranking_snapshot = ranking_leaderboards.get_latest_ranking_snapshot @@ -548,6 +550,147 @@ def validate_ranking_snapshot_postgres_selection(): postgres_rcon_storage.connect_postgres_compat = original_connect_postgres_compat +def validate_annual_ranking_cli_defaults(): + original_generate_annual_ranking_snapshot = annual_rankings.generate_annual_ranking_snapshot + captured = {} + + def fake_generate_annual_ranking_snapshot(**kwargs): + captured.update(kwargs) + return { + "status": "ok", + "snapshot": { + "generated_at": datetime(2026, 6, 9, 8, 0, 0, tzinfo=timezone.utc), + "window_start": date(2026, 1, 1), + }, + "items": [], + } + + annual_rankings.generate_annual_ranking_snapshot = fake_generate_annual_ranking_snapshot + + try: + stdout_buffer = StringIO() + with redirect_stdout(stdout_buffer): + exit_code = annual_rankings._main([ + "generate", + "--year", "2026", + "--server-key", "all-servers", + "--metric", "kills", + "--limit", "30", + ]) + require(exit_code == 0, "Annual ranking CLI should exit 0 for a valid command.") + require( + captured.get("db_path") is None, + "Annual ranking CLI should use PostgreSQL-compatible db_path=None by default.", + ) + serialized_default_payload = json.loads(stdout_buffer.getvalue()) + require( + serialized_default_payload.get("data", {}).get("snapshot", {}).get("generated_at") == "2026-06-09T08:00:00Z", + "Annual ranking CLI should serialize datetime values as ISO strings.", + ) + require( + serialized_default_payload.get("data", {}).get("snapshot", {}).get("window_start") == "2026-01-01", + "Annual ranking CLI should serialize date values as ISO strings.", + ) + + captured.clear() + stdout_buffer = StringIO() + with redirect_stdout(stdout_buffer): + exit_code = annual_rankings._main([ + "generate", + "--year", "2026", + "--server-key", "all-servers", + "--metric", "kills", + "--limit", "30", + "--sqlite-path", "backend/data/hll_vietnam_dev.sqlite3", + ]) + require(exit_code == 0, "Annual ranking CLI with --sqlite-path should exit 0.") + require( + captured.get("db_path") == Path("backend/data/hll_vietnam_dev.sqlite3"), + "Annual ranking CLI should pass the explicit --sqlite-path override through to generate_annual_ranking_snapshot.", + ) + finally: + annual_rankings.generate_annual_ranking_snapshot = original_generate_annual_ranking_snapshot + + +def validate_annual_ranking_postgres_selection(): + original_database_url = os.environ.get("HLL_BACKEND_DATABASE_URL") + original_initialize_materialized = annual_rankings.initialize_rcon_materialized_storage + original_connect_postgres_compat = postgres_rcon_storage.connect_postgres_compat + original_count_matches_in_window = annual_rankings._count_matches_in_window + original_find_existing_snapshot = annual_rankings._find_existing_snapshot + original_fetch_annual_ranking_rows = annual_rankings._fetch_annual_ranking_rows + original_delete_existing_snapshot = annual_rankings._delete_existing_snapshot + original_insert_snapshot = annual_rankings._insert_snapshot + original_insert_items = annual_rankings._insert_items + original_get_snapshot = annual_rankings._get_snapshot + original_list_items = annual_rankings._list_items + calls = {"postgres_connect": 0} + + @contextmanager + def fake_connect_postgres_compat(): + calls["postgres_connect"] += 1 + yield object() + + os.environ["HLL_BACKEND_DATABASE_URL"] = "postgresql://validation-user:validation-pass@127.0.0.1:5432/hll_validation" + annual_rankings.initialize_rcon_materialized_storage = ( + lambda db_path=None: Path("backend/data/hll_vietnam_dev.sqlite3") + ) + postgres_rcon_storage.connect_postgres_compat = fake_connect_postgres_compat + annual_rankings._count_matches_in_window = lambda **kwargs: 0 + annual_rankings._find_existing_snapshot = lambda **kwargs: None + annual_rankings._fetch_annual_ranking_rows = lambda **kwargs: [] + annual_rankings._delete_existing_snapshot = lambda **kwargs: None + annual_rankings._insert_snapshot = lambda **kwargs: 1 + annual_rankings._insert_items = lambda **kwargs: None + annual_rankings._get_snapshot = lambda **kwargs: { + "id": 1, + "year": 2026, + "server_key": "all-servers", + "metric": "kills", + "limit_size": 30, + "generated_at": "2026-06-09T08:00:00Z", + } + annual_rankings._list_items = lambda **kwargs: [] + + try: + require( + use_postgres_rcon_storage(explicit_sqlite_path=None) is True, + "PostgreSQL storage should be selected for annual ranking when DATABASE_URL is configured and no explicit SQLite path is provided.", + ) + require( + use_postgres_rcon_storage(explicit_sqlite_path=Path("backend/data/hll_vietnam_dev.sqlite3")) is False, + "Explicit SQLite paths should still disable PostgreSQL storage selection for annual ranking.", + ) + payload = generate_annual_ranking_snapshot( + year=2026, + server_key="all-servers", + metric="kills", + limit=30, + replace_existing=True, + db_path=None, + ) + require(payload.get("status") == "ok", "Annual ranking snapshot generator should still return ok in the PostgreSQL selection validation.") + require( + calls["postgres_connect"] == 1, + "Annual ranking snapshot generator should use PostgreSQL when db_path=None and DATABASE_URL is configured.", + ) + finally: + if original_database_url is None: + os.environ.pop("HLL_BACKEND_DATABASE_URL", None) + else: + os.environ["HLL_BACKEND_DATABASE_URL"] = original_database_url + annual_rankings.initialize_rcon_materialized_storage = original_initialize_materialized + postgres_rcon_storage.connect_postgres_compat = original_connect_postgres_compat + annual_rankings._count_matches_in_window = original_count_matches_in_window + annual_rankings._find_existing_snapshot = original_find_existing_snapshot + annual_rankings._fetch_annual_ranking_rows = original_fetch_annual_ranking_rows + annual_rankings._delete_existing_snapshot = original_delete_existing_snapshot + annual_rankings._insert_snapshot = original_insert_snapshot + annual_rankings._insert_items = original_insert_items + annual_rankings._get_snapshot = original_get_snapshot + annual_rankings._list_items = original_list_items + + def validate_postgres_player_search_index_schema_path(): require( "CREATE TABLE IF NOT EXISTS player_search_index" in postgres_rcon_storage.RCON_SCHEMA_SQL, @@ -1506,6 +1649,8 @@ validate_ranking_snapshot_cli_defaults() validate_ranking_snapshot_postgres_selection() validate_ranking_snapshot_bulk_refresh() validate_ranking_snapshot_bulk_partial_failure() +validate_annual_ranking_cli_defaults() +validate_annual_ranking_postgres_selection() validate_postgres_player_search_index_schema_path() validate_player_search_index_cli_defaults() validate_player_search_index_refresh_and_search() @@ -1894,6 +2039,8 @@ print(json.dumps({ "ranking-snapshot-postgres-selection", "ranking-snapshot-bulk-refresh", "ranking-snapshot-bulk-partial-failure", + "annual-ranking-cli-postgres-default", + "annual-ranking-postgres-selection", "ranking-snapshot-generator", "ranking-snapshot-ready", "ranking-snapshot-missing",