fix: address PR #177 review — move import os to top-level, add audit idempotency guard

- Move `import os` from inside evaluate_game() to module top-level imports
  (lazy imports are only for circular dependency avoidance)
- Add get_or_none idempotency guard before RefractorBoostAudit.create()
  inside db.atomic() to prevent IntegrityError on UNIQUE(card_state, tier)
  constraint in PostgreSQL when apply_tier_boost is called twice for the
  same tier
- Update atomicity test stub to provide card_state/tier attributes for
  the new Peewee expression in the idempotency guard

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-30 13:16:27 -05:00
parent 6a176af7da
commit 7f17c9b9f2
3 changed files with 20 additions and 4 deletions

View File

@ -1,3 +1,5 @@
import os
from fastapi import APIRouter, Depends, HTTPException, Query
import logging
from typing import Optional
@ -307,8 +309,6 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized")
import os
from ..db_engine import RefractorCardState, Player, StratPlay
from ..services.refractor_boost import apply_tier_boost
from ..services.refractor_evaluator import evaluate_card

View File

@ -667,7 +667,11 @@ def apply_tier_boost(
audit_data["battingcard"] = new_card.id
else:
audit_data["pitchingcard"] = new_card.id
_audit_model.create(**audit_data)
existing_audit = _audit_model.get_or_none(
(_audit_model.card_state == card_state.id) & (_audit_model.tier == new_tier)
)
if existing_audit is None:
_audit_model.create(**audit_data)
# 8b. Update RefractorCardState — this is the SOLE tier write on tier-up.
card_state.current_tier = new_tier

View File

@ -1115,7 +1115,19 @@ class TestAtomicity:
"""
class _FailingAuditModel:
"""Stub that raises on .create() to simulate audit write failure."""
"""Stub that raises on .create() to simulate audit write failure.
Provides card_state/tier attributes for the Peewee expression in the
idempotency guard, and get_or_none returns None so it proceeds to
create(), which then raises to simulate the failure.
"""
card_state = RefractorBoostAudit.card_state
tier = RefractorBoostAudit.tier
@staticmethod
def get_or_none(*args, **kwargs):
return None
@staticmethod
def create(**kwargs):