StocKing / tests /test_return_api.py
adoomy's picture
Add automatic shareholder return estimates and numeric yield sorting
992d9b8
Raw History Blame Contribute Delete
11.2 kB
from datetime import date, datetime
from unittest.mock import Mock
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
import auth
import main
from database import init_db
from models import Base, Company, PriceSnapshot, AnnualData, User, ReturnAudit, ReturnDocument, ReturnEvent, ReturnPolicy
@pytest.fixture
def api():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
db = factory()
year = date.today().year
db.add_all([Company(ticker="005930", name="테스트", market="KOSPI"),
User(id=1, google_id="admin", email="admin@example.com", is_admin=True),
PriceSnapshot(ticker="005930", current_price=10000, market_cap=10000, updated_at=datetime.utcnow()),
AnnualData(ticker="005930", year=year, is_estimate=True, dividend_per_share=400, net_income=1000, shares_outstanding=100000000),
AnnualData(ticker="005930", year=year+1, is_estimate=True, dividend_per_share=500),
AnnualData(ticker="005930", year=year-1, is_estimate=False, shares_outstanding=100000000)])
db.commit()
def get_db():
yield db
main.app.dependency_overrides[main.get_db] = get_db
auth_user = {"value": db.get(User, 1)}
main.app.dependency_overrides[auth.get_current_user] = lambda: auth_user["value"]
main.invalidate_screener_cache()
client = TestClient(main.app)
yield client, db, auth_user, year
main.app.dependency_overrides.clear()
main.invalidate_screener_cache()
db.close()
engine.dispose()
def payload(year, **kwargs):
return {"ticker": "005930", "source_key": "manual:first", "source_url": "https://example.com/ir", "title": "소각 결정", "evidence": "보통주 소각 300억원", "status": "pending",
"events": [{"year": year, "amount": 300, "scope": "common", "evidence": "보통주 소각 300억원"}], **kwargs}
def test_approval_invalidates_cache_and_detail_matches(api):
client, db, _, year = api
assert client.get('/api/screener').json()[0]['est_total_shareholder_yield'] is None
d = client.post('/api/admin/returns/documents', json=payload(year)).json()
assert d['status'] == 'pending'
assert client.get('/api/screener').json()[0]['return_status'] == 'unconfirmed'
response = client.put(f"/api/admin/returns/documents/{d['id']}", json=payload(year, status='approved'))
assert response.status_code == 200, response.text
r = client.get('/api/screener').json()[0]
assert r['est_total_shareholder_yield'] == 7
detail = client.get(f'/api/stock/005930?year={year}').json()
assert detail['shareholder_returns']['est_total_shareholder_yield'] == 7
assert 'history' in detail and 'consensus' in detail
assert client.get(f'/api/screener?year={year+1}').json()[0]['est_total_shareholder_yield'] is None
assert client.get(f'/api/stock/005930?year={year-1}').status_code == 400
assert db.query(ReturnAudit).count() == 2
assert client.get(f"/api/admin/returns/documents/{d['id']}/audit").status_code == 200
def test_admin_permissions(api):
client, _, user, year = api
user['value'] = None
assert client.get('/api/admin/returns/documents').status_code == 401
assert client.post('/api/admin/returns/documents', json=payload(year)).status_code == 401
user['value'] = User(id=2, is_admin=False)
assert client.get('/api/admin/returns/sources').status_code == 403
assert client.post('/api/admin/returns/documents', json=payload(year)).status_code == 403
assert client.get('/api/screener').status_code == 200
def test_automatic_application_cancel_restore_and_audit(api):
client,db,user,year=api
d=client.post('/api/admin/returns/documents',json=payload(year,status='active')).json()
assert d['status'] == 'active'
assert client.get('/api/screener').json()[0]['est_total_shareholder_yield'] == 7
path=f"/api/admin/returns/documents/{d['id']}/status"
assert client.post(path,json={'action':'cancel','reason':'중복 공시'}).json()['status'] == 'excluded'
assert client.get('/api/screener').json()[0]['est_total_shareholder_yield'] is None
detail=client.get(f'/api/stock/005930?year={year}').json()['shareholder_returns']
assert detail['return_sources'][0]['status'] == 'excluded'
assert detail['return_sources'][0]['events'][0]['amount'] == 300
assert client.post(path,json={'action':'cancel'}).status_code == 200
assert db.query(ReturnAudit).count() == 2
assert client.post(path,json={'action':'restore'}).json()['status'] == 'active'
assert client.get('/api/screener').json()[0]['est_total_shareholder_yield'] == 7
audits=client.get(f"/api/admin/returns/documents/{d['id']}/audit").json()
assert [a['action'] for a in audits] == ['review','cancel','restore']
assert '중복 공시' in audits[1]['snapshot']
user['value']=None
assert client.post(path,json={'action':'cancel'}).status_code == 401
user['value']=User(id=2,is_admin=False)
assert client.post(path,json={'action':'cancel'}).status_code == 403
def test_restore_reference_does_not_invent_missing_amount(api):
client,db,user,year=api
d=ReturnDocument(ticker='005930',source_key='ref',source_url='https://example.com',title='참고',status='excluded',error='dart_status_014')
db.add(d);db.commit()
response=client.post(f'/api/admin/returns/documents/{d.id}/status',json={'action':'restore'})
assert response.json()['status'] == 'reference'
assert client.get('/api/screener').json()[0]['est_total_shareholder_yield'] is None
def test_review_list_uses_three_queries_for_many_documents(api):
from sqlalchemy import event
client,db,users,year=api
for i in range(200):
doc=ReturnDocument(ticker='005930',source_key=f'bulk:{i}',source_url='https://example.com/ir',title=f'소각 {i}',status='pending')
db.add(doc);db.flush()
db.add(ReturnEvent(document_id=doc.id,year=year,amount=i,scope='common'))
db.add(ReturnPolicy(document_id=doc.id,year=year,kind='cancellation',amount=i,scope='common'))
db.commit()
assert users['value'].is_admin # Load authentication state before measuring list queries.
queries=[]
def record(conn,cursor,statement,parameters,context,executemany):
if statement.lstrip().upper().startswith('SELECT'):
queries.append(statement)
engine=db.get_bind()
event.listen(engine,'before_cursor_execute',record)
try:
response=client.get('/api/admin/returns/documents')
finally:
event.remove(engine,'before_cursor_execute',record)
assert response.status_code == 200
documents=response.json()
assert len(documents) == 200
assert all(len(d['events']) == len(d['policies']) == 1 for d in documents)
assert len(queries) == 3
def test_supersession_cycle_and_duplicate_keys(api):
client, _, _, year = api
one = client.post('/api/admin/returns/documents', json=payload(year, status='approved')).json()
assert client.post('/api/admin/returns/documents', json=payload(year)).status_code == 409
two = client.post('/api/admin/returns/documents', json=payload(year, source_key='manual:second', supersedes_id=one['id'], status='approved')).json()
assert client.put(f"/api/admin/returns/documents/{one['id']}", json=payload(year, supersedes_id=two['id'], status='approved')).status_code == 422
assert client.get('/api/screener').json()[0]['est_cancellation_amount'] == 300
assert client.post('/api/admin/returns/documents', json=payload(year, source_key='manual:third', supersedes_id=one['id'], status='approved')).status_code == 409
def test_correction_requires_previous_source(api):
client,_,_,year=api
assert client.post('/api/admin/returns/documents',json=payload(year,title='기재정정 소각 결정',status='approved')).status_code == 422
def test_external_review_changes_invalidate_worker_cache(api):
from return_api import ReviewInput, write_review
client,db,_,year=api
assert client.get('/api/screener').json()[0]['est_cancellation_amount'] is None
# Simulate another process updating the database without clearing Python caches.
write_review(db,ReviewInput(**payload(year,status='approved')))
assert client.get('/api/screener').json()[0]['est_cancellation_amount'] == 300
def test_sources_upsert_and_url_validation(api):
client, db, _, year = api
body = {'ticker':'005930','url':'https://example.com/ir','is_index':True}
one = client.post('/api/admin/returns/sources',json=body).json()
two = client.post('/api/admin/returns/sources',json={**body,'enabled':False}).json()
assert one['id'] == two['id'] and not two['enabled']
assert client.post('/api/admin/returns/sources',json={**body,'url':'file:///etc/passwd'}).status_code == 422
def test_price_refresh_clears_cap_and_invalidates_cache(api, monkeypatch):
client, db, _, year = api
client.post('/api/admin/returns/documents',json=payload(year,status='approved'))
assert client.get('/api/screener').json()[0]['est_total_shareholder_yield'] == 7
import requests
monkeypatch.setattr(requests, 'get', lambda *a, **k: Mock(raise_for_status=lambda:None, json=lambda:{'closePrice':'20,000'}))
response = client.post('/api/refresh/price/005930')
assert response.json()['market_cap'] == 20000
assert client.get('/api/screener').json()[0]['est_total_shareholder_yield'] == 3.5
old = db.query(AnnualData).filter_by(is_estimate=False).first()
old.shares_outstanding = None
db.commit()
assert client.post('/api/refresh/price/005930').json()['market_cap'] is None
assert client.get('/api/screener').json()[0]['est_total_shareholder_yield'] is None
def test_existing_sqlite_database_upgrade_preserves_data(monkeypatch):
import database
engine = create_engine('sqlite://', poolclass=StaticPool)
# Simulate the pre-feature schema and a populated company record.
old_tables = [t for name,t in Base.metadata.tables.items() if not name.startswith('return_')]
Base.metadata.create_all(engine, tables=old_tables)
with engine.begin() as conn:
conn.execute(text("INSERT INTO company (ticker,name) VALUES ('005930','기존기업')"))
monkeypatch.setattr(database,'engine',engine)
monkeypatch.setattr(database,'_is_postgres',False)
init_db()
init_db()
assert 'return_document' in inspect(engine).get_table_names()
with engine.connect() as conn:
assert conn.execute(text("SELECT name FROM company WHERE ticker='005930'")).scalar() == '기존기업'
engine.dispose()
def test_postgres_schema_compiles_without_altering_existing_tables():
from sqlalchemy.schema import CreateTable
from sqlalchemy.dialects import postgresql
for name,table in Base.metadata.tables.items():
if name.startswith('return_'):
sql = str(CreateTable(table).compile(dialect=postgresql.dialect()))
assert 'CREATE TABLE' in sql