From b583c709cc198f0c4db7499190a0c2b8f942fcb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 02:04:56 +0000 Subject: [PATCH] =?UTF-8?q?fix(pairs):=20POST=20/operated=20idempotente=20?= =?UTF-8?q?(409)=20+=20semeia=20env=20na=201=C2=AA=20adi=C3=A7=C3=A3o=20(M?= =?UTF-8?q?2+M3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dois bugs no write de pares operados, revelados pelo uso real na VPS. M2 — POST /v1/pairs/operated não era idempotente: adicionar um par já operado retornava 201 de novo (ETH/USDT 2×). A tabela tem symbol PRIMARY KEY e o store usa INSERT OR IGNORE (nunca duplica de fato), mas o contrato HTTP estava errado. Fix: checar contra o conjunto EFETIVO operated_pairs() (não só a tabela crua) — cobre o duplicado na tabela E o par já operado via fallback do env — e retornar 409 already_operated (idioma 4xx da casa: HTTPException detail={error,message}). M3 — armadilha da precedência DB>env na 1ª adição: com a tabela vazia + env com pares, o console mostrava BTC operando (fallback env); ao adicionar o 1º par pela UI a tabela ficava não-vazia e os pares do env caíam em silêncio (BTC sumia). Fix: quando a tabela CRUA está vazia (store.symbols(), não operated_pairs() — que nunca é vazio por causa do default), semear o conjunto efetivo do env ANTES de inserir o novo par, tornando o add incremental e preservando a intenção do operador. Auditoria honesta: before = operated_pairs() (efetivo) → o diff config_changed vira ["BTC/USDT"]→["BTC/USDT","ETH/USDT"] (migração env→DB documentada), não []→["ETH"]. Sem surpresa no loop em execução: semeia os mesmos símbolos do env que o loop já roda; o novo par só vale no próximo restart, como documentado (N8²). Testes (tests/api/test_operated_pairs.py, fixture env com SYMBOLS=BTC/USDT): - test_add_validates_and_persists atualizado (BTC agora semeado → {BTC,ETH}). - +test_add_is_idempotent_409 (dup na tabela → 409, sem duplicata). - +test_add_already_effective_via_env_is_409 (par do env → 409, sem semeadura). - +test_first_add_seeds_env_pairs (reproduz/corrige o incidente do BTC). - +test_first_add_audit_diff_is_incremental (diff honesto na auditoria). - test_db_wins_over_env intacto (é store-level, não passa pela rota). Validação: pytest completo verde, cobertura 78.13% ≥ 72%. Só backend + testes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UZ3vLNTHKekjQVtjFVRi5D --- src/api/routes/pairs.py | 17 ++++++++++- tests/api/test_operated_pairs.py | 48 +++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/api/routes/pairs.py b/src/api/routes/pairs.py index 2f65d08..3bd05b6 100644 --- a/src/api/routes/pairs.py +++ b/src/api/routes/pairs.py @@ -125,8 +125,23 @@ async def add_operated( principal: Principal = Depends(require_perm("edit_settings")), ) -> APIResponse[PairsOut]: store = OperatedPairStore() - before = store.symbols() sym = await _validate_addable(body.symbol, client) + # `before` é o conjunto EFETIVO (DB > env), não a tabela crua: é o que o loop + # realmente opera e o que a auditoria deve registrar. + before = operated_pairs() + # M2: idempotência. Checar contra o efetivo cobre o duplicado na tabela E o par + # já operado via fallback do env (tabela vazia) — nos dois casos é 409, não 201. + if sym in before: + raise HTTPException(status_code=409, detail={ + "error": "already_operated", + "message": f"'{sym}' já está no conjunto operado."}) + # M3: na PRIMEIRA adição (tabela crua vazia — `store.symbols()`, não + # `operated_pairs()`, que nunca é vazio por causa do default), semeia o conjunto + # efetivo do env ANTES de adicionar o novo par. Sem isso, inserir o 1º par + # tornaria a tabela não-vazia e os pares do env (DB > env) cairiam em silêncio. + if not store.symbols(): + for s in before: + store.add(s) store.add(sym) after = store.symbols() ledger.log_decision("config_changed", { diff --git a/tests/api/test_operated_pairs.py b/tests/api/test_operated_pairs.py index 35ea18e..2c3b4bb 100644 --- a/tests/api/test_operated_pairs.py +++ b/tests/api/test_operated_pairs.py @@ -43,7 +43,53 @@ def test_add_validates_and_persists(env): r = c.post("/v1/pairs/operated", json={"symbol": "ETH/USDT"}) assert r.status_code == 201, r.text assert "ETH/USDT" in [o["symbol"] for o in r.json()["data"]["operados"]] - assert OperatedPairStore().symbols() == ["ETH/USDT"] + # M3: a 1ª adição semeia o conjunto efetivo do env (SYMBOLS=BTC/USDT) antes de + # inserir o novo par — o BTC não some ao materializar a tabela. + assert set(OperatedPairStore().symbols()) == {"BTC/USDT", "ETH/USDT"} + + +def test_add_is_idempotent_409(env): + # M2: adicionar um par já operado retorna 409 (não 201), sem linha duplicada. + OperatedPairStore().add("ETH/USDT") # tabela já não-vazia → sem semeadura + c = TestClient(create_app()) + r = c.post("/v1/pairs/operated", json={"symbol": "ETH/USDT"}) + assert r.status_code == 409, r.text + assert r.json()["error"] == "already_operated" # handler passa o detail como body + assert OperatedPairStore().symbols() == ["ETH/USDT"] # sem duplicata + + +def test_add_already_effective_via_env_is_409(env): + # M2: um par já operado via fallback do env (tabela vazia) também é 409 — e NÃO + # dispara semeadura (a request de conflito não altera estado). + assert OperatedPairStore().symbols() == [] + c = TestClient(create_app()) + r = c.post("/v1/pairs/operated", json={"symbol": "BTC/USDT"}) # BTC vem do env + assert r.status_code == 409, r.text + assert OperatedPairStore().symbols() == [] # nada semeado/adicionado + + +def test_first_add_seeds_env_pairs(env): + # M3: reproduz e corrige o incidente do BTC — tabela vazia + env=BTC, adicionar + # ETH pela primeira vez preserva o BTC (não cai em silêncio na transição env→DB). + assert OperatedPairStore().symbols() == [] + assert operated_pairs() == ["BTC/USDT"] # env fallback + c = TestClient(create_app()) + r = c.post("/v1/pairs/operated", json={"symbol": "ETH/USDT"}) + assert r.status_code == 201, r.text + assert set(OperatedPairStore().symbols()) == {"BTC/USDT", "ETH/USDT"} + assert set(operated_pairs()) == {"BTC/USDT", "ETH/USDT"} + + +def test_first_add_audit_diff_is_incremental(env): + # M3: a auditoria registra a migração honesta ["BTC/USDT"]→["BTC","ETH"], não + # ["]→["ETH"] (que escondia o BTC sumindo). + ledger = env + c = TestClient(create_app()) + c.post("/v1/pairs/operated", json={"symbol": "ETH/USDT"}) + evt = next(e for e in ledger.get_events("config_changed") + if e["data"].get("scope") == "pairs") + assert evt["data"]["before"]["operated"] == ["BTC/USDT"] + assert set(evt["data"]["after"]["operated"]) == {"BTC/USDT", "ETH/USDT"} def test_add_rejects_non_usdt_and_non_allowlisted(env):