diff --git a/BUGS.md b/BUGS.md index 5c7741c..04ed1c9 100644 --- a/BUGS.md +++ b/BUGS.md @@ -377,15 +377,6 @@ Fix: each as described, one regression test per item; for the `closing` wait, a `closing_stalled` audit row every 1800s past the deadline and `closing_waiting_since` on `GET /rounds/current` (spec: TX-29). -### B-97 — Migrations can silently drop the expression indexes - -`batch_alter_table` on SQLite rebuilds the table and drops `ix_rounds_single_active` -and `ix_users_username_lower` with only a warning; autogenerate ignores them; no test -runs the migrations. - -Fix: a test that runs `alembic upgrade head` on an empty DB and asserts both indexes -exist; any batch migration on those tables recreates them explicitly. - ### B-98 — Coin selection stops at the first covering UTXO, refusing a payable bet Found while writing the spec on 2026-09-28 (medium). `select_utxos` returns as soon diff --git a/CLAUDE.md b/CLAUDE.md index de54580..d93ae08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin ## Project status -All 10 stages of the original build order are code-complete and unit-tested — 394 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. +All 10 stages of the original build order are code-complete and unit-tested — 396 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only. @@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace) PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip -python -m pytest # all 394 tests +python -m pytest # all 396 tests python -m pytest tests/unit/test_hd.py # one file python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test ``` diff --git a/README.md b/README.md index 2014e94..39c4a85 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ python -m pytest # all tests python -m pytest tests/unit/test_hd.py # one file ``` -394 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +396 unit tests cover HD derivation, PSBT building, the Electrum client, bets, deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config, the pending-inclusive balance calculation, and the SSE push channel. No automated integration tests against a live Electrum connection — mainnet diff --git a/docs/architettura.md b/docs/architettura.md index d09d16c..be01fa8 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -932,8 +932,8 @@ italiano. e l'avvio dei sei task; la route SSE `round_stream` (è testato solo il broadcaster); `UserLocks` e la sua acquisizione nelle route di scommessa e prelievo; il codice JavaScript dei frontend; la parità delle chiavi tra le - lingue di `i18n.js`; le migrazioni Alembic (lo schema dei test nasce da - `Base.metadata.create_all`). Le verifiche dal vivo sono state manuali + lingue di `i18n.js`; le migrazioni Alembic su dati esistenti (la catena è eseguita solo su un database vuoto, [ARC-62]; lo schema degli + altri test nasce da `Base.metadata.create_all`). Le verifiche dal vivo sono state manuali (`scripts/electrum_smoke_test.py` e transazioni reali su mainnet; prelievo e RBF mai trasmessi dal vivo, vedi [limiti-noti.md](limiti-noti.md)). La revisione del 2026-09-28 ha osservato che nessuna delle sue falle @@ -946,8 +946,7 @@ italiano. tabella con un solo avviso. Ogni migrazione batch su `rounds` o `users` li ricrea esplicitamente. *Codice:* `migrations/env.py` · `migrations/versions/` · - *Test:* — - *Stato:* da implementare (B-97) · *Oggi:* nessun test esegue le migrazioni. + *Test:* `tests/unit/test_migrations.py` - **[ARC-63]** **Test di parità delle traduzioni.** Un test legge `TRANSLATIONS` da `app/static/i18n.js` e fallisce se una delle sette lingue ha un insieme di chiavi diverso da quello di `en` ([ARC-51]). diff --git a/docs/modello-dati.md b/docs/modello-dati.md index a7a72cd..984a031 100644 --- a/docs/modello-dati.md +++ b/docs/modello-dati.md @@ -1453,9 +1453,8 @@ letti con una scansione completa. secondo username uguale a meno delle maiuscole vengano rifiutati con `IntegrityError`. Il test gira con la suite unitaria, quindi una migrazione che perde uno dei due indici non entra nel repository. - *Codice:* `migrations/env.py` · *Test:* — - *Stato:* da implementare (B-97) · *Oggi:* nessun test esegue le migrazioni - (vedi sotto). + *Codice:* `migrations/env.py` · + *Test:* `tests/unit/test_migrations.py` - **[DAT-57]** Una migrazione che incontra dati incompatibili con un nuovo vincolo si comporta in uno di due modi. Se esiste una correzione **senza @@ -1477,10 +1476,9 @@ letti con una scansione completa. l'indice. È la stessa scelta che `get_active_round` faceva già in silenzio. *Codice:* `migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py` -Oggi nessun test esegue le migrazioni: i test unitari creano lo schema -direttamente dal modello (`Base.metadata.create_all`), quindi verificano il -modello, non la catena di revisioni. [DAT-75] (B-97) aggiunge il primo test -sulla catena. +I test unitari creano lo schema direttamente dal modello +(`Base.metadata.create_all`), quindi verificano il modello, non la catena di +revisioni; [DAT-75] aggiunge il test che esegue la catena su un database vuoto. ### Storia dello schema diff --git a/docs/storico-bug.md b/docs/storico-bug.md index e2ec911..66e1d43 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -245,16 +245,16 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). | B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | **aperto** | — | SEC, API | | B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | **aperto** | — | RND, BET | | B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | **aperto** | — | TX, BET, WDR, DEP | -| B-97 | Le migrazioni possono eliminare in silenzio gli indici su espressione | Media | **aperto** | — | DAT | +| B-97 | Le migrazioni possono eliminare in silenzio gli indici su espressione | Media | *da registrare* | `test_migrations.py` | DAT | | B-98 | La selezione delle monete si ferma alla prima che copre l'importo e rifiuta una scommessa pagabile | Media | **aperto** | — | BET, WDR | ## Stato attuale - **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera - come limite accettato; dalla revisione del 2026-09-28: B-88, B-75. + come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97. - **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (9 critici, - 8 alti, 7 medi), da implementare nell'ordine di + 8 alti, 6 medi), da implementare nell'ordine di [piano-correzioni.md](piano-correzioni.md). - **Prossimo numero libero:** `B-99`. diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py new file mode 100644 index 0000000..2ed1154 --- /dev/null +++ b/tests/unit/test_migrations.py @@ -0,0 +1,76 @@ +"""B-97 (DAT-75, DAT-56, ARC-62, ARC-64): the Alembic chain is executed, not assumed. + +The two expression indexes that carry invariants — `ix_rounds_single_active` ("at +most one active round", B-09) and `ix_users_username_lower` ("usernames are +case-insensitive", B-57) — are invisible to autogenerate, and `batch_alter_table` +on SQLite rebuilds the table and drops them with only a warning. Every other test +builds its schema with `Base.metadata.create_all`, so a migration that lost one of +them would reach production unnoticed. This one runs `alembic upgrade head` on an +empty database, exactly as the container does at startup. + +Alembic runs in a subprocess: env.py calls `asyncio.run` and `fileConfig`, neither of +which belongs inside the pytest process. +""" + +import os +import re +import sqlite3 +import subprocess +import sys +from pathlib import Path + +import pytest + +_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture(scope="module") +def migrated_db(tmp_path_factory) -> Path: + db_path = tmp_path_factory.mktemp("migrations") / "migrated.db" + env = {**os.environ, "DATABASE_URL": f"sqlite+aiosqlite:///{db_path}"} + result = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=_ROOT, + env=env, + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, f"alembic upgrade head failed:\n{result.stderr[-3000:]}" + return db_path + + +def _index_sql(db_path: Path, name: str) -> str | None: + with sqlite3.connect(db_path) as conn: + row = conn.execute("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", (name,)).fetchone() + return re.sub(r"\s+", " ", row[0]).lower() if row else None + + +def test_the_expression_indexes_survive_the_whole_migration_chain(migrated_db): + """DAT-75 / ARC-62 / B-97: both indexes exist, with their definition, at head.""" + single_active = _index_sql(migrated_db, "ix_rounds_single_active") + assert single_active is not None, "ix_rounds_single_active was lost by a migration" + assert "unique index" in single_active + assert "on rounds ((1))" in single_active + for status in ("open", "closing", "drawing", "paying_out"): + assert f"'{status}'" in single_active + + username_lower = _index_sql(migrated_db, "ix_users_username_lower") + assert username_lower is not None, "ix_users_username_lower was lost by a migration" + assert "unique index" in username_lower + assert "lower(username)" in username_lower + + +def test_the_migrated_database_still_enforces_both_invariants(migrated_db): + """DAT-75 / B-97: a second active round and a case-variant username are refused.""" + with sqlite3.connect(migrated_db) as conn: + conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('open', '2026-01-01 00:00:00')") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closing', '2026-01-01 00:00:00')") + # A closed round is not active, so any number of them may coexist. + conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closed', '2026-01-01 00:00:00')") + + columns = "(username, password_hash, derivation_index, address, cached_balance_sats, token_version, created_at)" + conn.execute(f"INSERT INTO users {columns} VALUES ('Alice', 'x', 0, 'plm1qa', 0, 0, '2026-01-01 00:00:00')") + with pytest.raises(sqlite3.IntegrityError): + conn.execute(f"INSERT INTO users {columns} VALUES ('aLICE', 'x', 1, 'plm1qb', 0, 0, '2026-01-01 00:00:00')")