feat: add E2E tests for backend and frontend
Backend (pytest): - Auth flow tests (login, token, protected routes) - Portfolio CRUD and transaction tests - Strategy endpoint tests - Backtest flow tests - Snapshot and returns tests Frontend (Playwright): - Auth page tests - Portfolio navigation tests - Strategy page tests - Backtest page tests - Playwright configuration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e3b9ec1071
commit
efcfc0e090
3
backend/tests/__init__.py
Normal file
3
backend/tests/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Test package for Galaxy-PO backend.
|
||||
"""
|
||||
108
backend/tests/conftest.py
Normal file
108
backend/tests/conftest.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""
|
||||
Pytest configuration and fixtures for E2E tests.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
from typing import Generator
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.main import app
|
||||
from app.core.database import Base, get_db
|
||||
from app.models.user import User
|
||||
from app.core.auth import get_password_hash, create_access_token
|
||||
|
||||
|
||||
# Use in-memory SQLite for tests
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def override_get_db():
|
||||
"""Override database dependency for tests."""
|
||||
try:
|
||||
db = TestingSessionLocal()
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db() -> Generator[Session, None, None]:
|
||||
"""Create a fresh database for each test."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(db: Session) -> Generator[TestClient, None, None]:
|
||||
"""Create a test client with database override."""
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
# Create tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
# Cleanup
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def test_user(db: Session) -> User:
|
||||
"""Create a test user."""
|
||||
user = User(
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
hashed_password=get_password_hash("testpassword"),
|
||||
is_admin=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def admin_user(db: Session) -> User:
|
||||
"""Create an admin test user."""
|
||||
user = User(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
hashed_password=get_password_hash("adminpassword"),
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def auth_headers(test_user: User) -> dict:
|
||||
"""Create authorization headers for test user."""
|
||||
token = create_access_token(data={"sub": test_user.username})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def admin_auth_headers(admin_user: User) -> dict:
|
||||
"""Create authorization headers for admin user."""
|
||||
token = create_access_token(data={"sub": admin_user.username})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
3
backend/tests/e2e/__init__.py
Normal file
3
backend/tests/e2e/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
E2E tests for Galaxy-PO API.
|
||||
"""
|
||||
75
backend/tests/e2e/test_auth_flow.py
Normal file
75
backend/tests/e2e/test_auth_flow.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""
|
||||
E2E tests for authentication flow.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_health_check(client: TestClient):
|
||||
"""Test health check endpoint."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "healthy"}
|
||||
|
||||
|
||||
def test_login_success(client: TestClient, test_user):
|
||||
"""Test successful login."""
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={
|
||||
"username": "testuser",
|
||||
"password": "testpassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
def test_login_wrong_password(client: TestClient, test_user):
|
||||
"""Test login with wrong password."""
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={
|
||||
"username": "testuser",
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_login_nonexistent_user(client: TestClient):
|
||||
"""Test login with nonexistent user."""
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={
|
||||
"username": "nonexistent",
|
||||
"password": "password",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_get_current_user(client: TestClient, auth_headers):
|
||||
"""Test getting current user info."""
|
||||
response = client.get("/api/auth/me", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["username"] == "testuser"
|
||||
assert data["email"] == "test@example.com"
|
||||
|
||||
|
||||
def test_get_current_user_no_token(client: TestClient):
|
||||
"""Test getting current user without token."""
|
||||
response = client.get("/api/auth/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_get_current_user_invalid_token(client: TestClient):
|
||||
"""Test getting current user with invalid token."""
|
||||
response = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": "Bearer invalid_token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
140
backend/tests/e2e/test_backtest_flow.py
Normal file
140
backend/tests/e2e/test_backtest_flow.py
Normal file
@ -0,0 +1,140 @@
|
||||
"""
|
||||
E2E tests for backtest flow.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_create_backtest(client: TestClient, auth_headers):
|
||||
"""Test creating a backtest."""
|
||||
response = client.post(
|
||||
"/api/backtest",
|
||||
json={
|
||||
"strategy_type": "multi_factor",
|
||||
"strategy_params": {
|
||||
"weights": {"value": 0.3, "quality": 0.3, "momentum": 0.2, "f_score": 0.2}
|
||||
},
|
||||
"start_date": "2023-01-01",
|
||||
"end_date": "2023-12-31",
|
||||
"rebalance_period": "quarterly",
|
||||
"initial_capital": 100000000,
|
||||
"commission_rate": 0.00015,
|
||||
"slippage_rate": 0.001,
|
||||
"benchmark": "KODEX200",
|
||||
"top_n": 20,
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "id" in data
|
||||
assert data["status"] == "pending"
|
||||
|
||||
|
||||
def test_list_backtests(client: TestClient, auth_headers):
|
||||
"""Test listing backtests."""
|
||||
# Create a backtest first
|
||||
client.post(
|
||||
"/api/backtest",
|
||||
json={
|
||||
"strategy_type": "quality",
|
||||
"strategy_params": {"min_f_score": 6},
|
||||
"start_date": "2023-01-01",
|
||||
"end_date": "2023-06-30",
|
||||
"rebalance_period": "monthly",
|
||||
"initial_capital": 50000000,
|
||||
"commission_rate": 0.00015,
|
||||
"slippage_rate": 0.001,
|
||||
"benchmark": "KODEX200",
|
||||
"top_n": 10,
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
# List backtests
|
||||
response = client.get("/api/backtest", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
backtests = response.json()
|
||||
assert len(backtests) >= 1
|
||||
|
||||
|
||||
def test_get_backtest(client: TestClient, auth_headers):
|
||||
"""Test getting backtest details."""
|
||||
# Create a backtest
|
||||
create_response = client.post(
|
||||
"/api/backtest",
|
||||
json={
|
||||
"strategy_type": "value_momentum",
|
||||
"strategy_params": {"value_weight": 0.6, "momentum_weight": 0.4},
|
||||
"start_date": "2023-01-01",
|
||||
"end_date": "2023-03-31",
|
||||
"rebalance_period": "monthly",
|
||||
"initial_capital": 10000000,
|
||||
"commission_rate": 0.00015,
|
||||
"slippage_rate": 0.001,
|
||||
"benchmark": "KODEX200",
|
||||
"top_n": 5,
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
backtest_id = create_response.json()["id"]
|
||||
|
||||
# Get backtest
|
||||
response = client.get(f"/api/backtest/{backtest_id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == backtest_id
|
||||
assert data["strategy_type"] == "value_momentum"
|
||||
|
||||
|
||||
def test_delete_backtest(client: TestClient, auth_headers):
|
||||
"""Test deleting a backtest."""
|
||||
# Create a backtest
|
||||
create_response = client.post(
|
||||
"/api/backtest",
|
||||
json={
|
||||
"strategy_type": "multi_factor",
|
||||
"strategy_params": {},
|
||||
"start_date": "2023-01-01",
|
||||
"end_date": "2023-01-31",
|
||||
"rebalance_period": "monthly",
|
||||
"initial_capital": 1000000,
|
||||
"commission_rate": 0.00015,
|
||||
"slippage_rate": 0.001,
|
||||
"benchmark": "KODEX200",
|
||||
"top_n": 3,
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
backtest_id = create_response.json()["id"]
|
||||
|
||||
# Delete backtest
|
||||
response = client.delete(f"/api/backtest/{backtest_id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify deleted
|
||||
response = client.get(f"/api/backtest/{backtest_id}", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_backtest_requires_auth(client: TestClient):
|
||||
"""Test that backtest endpoints require authentication."""
|
||||
response = client.get("/api/backtest")
|
||||
assert response.status_code == 401
|
||||
|
||||
response = client.post(
|
||||
"/api/backtest",
|
||||
json={
|
||||
"strategy_type": "multi_factor",
|
||||
"strategy_params": {},
|
||||
"start_date": "2023-01-01",
|
||||
"end_date": "2023-12-31",
|
||||
"rebalance_period": "quarterly",
|
||||
"initial_capital": 100000000,
|
||||
"commission_rate": 0.00015,
|
||||
"slippage_rate": 0.001,
|
||||
"benchmark": "KODEX200",
|
||||
"top_n": 20,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
217
backend/tests/e2e/test_portfolio_flow.py
Normal file
217
backend/tests/e2e/test_portfolio_flow.py
Normal file
@ -0,0 +1,217 @@
|
||||
"""
|
||||
E2E tests for portfolio management flow.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_portfolio_crud_flow(client: TestClient, auth_headers):
|
||||
"""Test complete portfolio CRUD flow."""
|
||||
# Create portfolio
|
||||
response = client.post(
|
||||
"/api/portfolios",
|
||||
json={
|
||||
"name": "Test Portfolio",
|
||||
"portfolio_type": "pension",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
portfolio = response.json()
|
||||
portfolio_id = portfolio["id"]
|
||||
assert portfolio["name"] == "Test Portfolio"
|
||||
assert portfolio["portfolio_type"] == "pension"
|
||||
|
||||
# Get portfolio list
|
||||
response = client.get("/api/portfolios", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
portfolios = response.json()
|
||||
assert len(portfolios) == 1
|
||||
assert portfolios[0]["id"] == portfolio_id
|
||||
|
||||
# Get single portfolio
|
||||
response = client.get(f"/api/portfolios/{portfolio_id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == portfolio_id
|
||||
|
||||
# Update portfolio
|
||||
response = client.put(
|
||||
f"/api/portfolios/{portfolio_id}",
|
||||
json={"name": "Updated Portfolio"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Updated Portfolio"
|
||||
|
||||
# Delete portfolio
|
||||
response = client.delete(f"/api/portfolios/{portfolio_id}", headers=auth_headers)
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify deleted
|
||||
response = client.get(f"/api/portfolios/{portfolio_id}", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_targets_flow(client: TestClient, auth_headers):
|
||||
"""Test portfolio target allocation flow."""
|
||||
# Create portfolio
|
||||
response = client.post(
|
||||
"/api/portfolios",
|
||||
json={"name": "Target Test Portfolio", "portfolio_type": "general"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
portfolio_id = response.json()["id"]
|
||||
|
||||
# Set targets
|
||||
targets = [
|
||||
{"ticker": "005930", "target_ratio": 40},
|
||||
{"ticker": "000660", "target_ratio": 30},
|
||||
{"ticker": "035420", "target_ratio": 30},
|
||||
]
|
||||
response = client.put(
|
||||
f"/api/portfolios/{portfolio_id}/targets",
|
||||
json=targets,
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert len(result) == 3
|
||||
assert sum(t["target_ratio"] for t in result) == 100
|
||||
|
||||
# Get targets
|
||||
response = client.get(
|
||||
f"/api/portfolios/{portfolio_id}/targets",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 3
|
||||
|
||||
# Test invalid targets (not 100%)
|
||||
invalid_targets = [
|
||||
{"ticker": "005930", "target_ratio": 50},
|
||||
{"ticker": "000660", "target_ratio": 30},
|
||||
]
|
||||
response = client.put(
|
||||
f"/api/portfolios/{portfolio_id}/targets",
|
||||
json=invalid_targets,
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_holdings_flow(client: TestClient, auth_headers):
|
||||
"""Test portfolio holdings flow."""
|
||||
# Create portfolio
|
||||
response = client.post(
|
||||
"/api/portfolios",
|
||||
json={"name": "Holdings Test Portfolio", "portfolio_type": "general"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
portfolio_id = response.json()["id"]
|
||||
|
||||
# Set holdings
|
||||
holdings = [
|
||||
{"ticker": "005930", "quantity": 10, "avg_price": 70000},
|
||||
{"ticker": "000660", "quantity": 5, "avg_price": 120000},
|
||||
]
|
||||
response = client.put(
|
||||
f"/api/portfolios/{portfolio_id}/holdings",
|
||||
json=holdings,
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert len(result) == 2
|
||||
|
||||
# Get holdings
|
||||
response = client.get(
|
||||
f"/api/portfolios/{portfolio_id}/holdings",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 2
|
||||
|
||||
|
||||
def test_transaction_flow(client: TestClient, auth_headers):
|
||||
"""Test transaction recording flow."""
|
||||
# Create portfolio with initial holdings
|
||||
response = client.post(
|
||||
"/api/portfolios",
|
||||
json={"name": "Transaction Test Portfolio", "portfolio_type": "general"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
portfolio_id = response.json()["id"]
|
||||
|
||||
# Buy transaction
|
||||
response = client.post(
|
||||
f"/api/portfolios/{portfolio_id}/transactions",
|
||||
json={
|
||||
"ticker": "005930",
|
||||
"tx_type": "buy",
|
||||
"quantity": 10,
|
||||
"price": 70000,
|
||||
"executed_at": "2024-01-15T10:00:00",
|
||||
"memo": "Initial purchase",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
tx = response.json()
|
||||
assert tx["ticker"] == "005930"
|
||||
assert tx["tx_type"] == "buy"
|
||||
|
||||
# Verify holdings updated
|
||||
response = client.get(
|
||||
f"/api/portfolios/{portfolio_id}/holdings",
|
||||
headers=auth_headers,
|
||||
)
|
||||
holdings = response.json()
|
||||
assert len(holdings) == 1
|
||||
assert holdings[0]["ticker"] == "005930"
|
||||
assert holdings[0]["quantity"] == 10
|
||||
|
||||
# Sell transaction
|
||||
response = client.post(
|
||||
f"/api/portfolios/{portfolio_id}/transactions",
|
||||
json={
|
||||
"ticker": "005930",
|
||||
"tx_type": "sell",
|
||||
"quantity": 5,
|
||||
"price": 75000,
|
||||
"executed_at": "2024-01-16T10:00:00",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Verify holdings updated
|
||||
response = client.get(
|
||||
f"/api/portfolios/{portfolio_id}/holdings",
|
||||
headers=auth_headers,
|
||||
)
|
||||
holdings = response.json()
|
||||
assert holdings[0]["quantity"] == 5
|
||||
|
||||
# Get transactions
|
||||
response = client.get(
|
||||
f"/api/portfolios/{portfolio_id}/transactions",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
txs = response.json()
|
||||
assert len(txs) == 2
|
||||
|
||||
|
||||
def test_unauthorized_access(client: TestClient, auth_headers):
|
||||
"""Test that users can't access other users' portfolios."""
|
||||
# Create portfolio with test user
|
||||
response = client.post(
|
||||
"/api/portfolios",
|
||||
json={"name": "Private Portfolio", "portfolio_type": "general"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
portfolio_id = response.json()["id"]
|
||||
|
||||
# Try to access without auth
|
||||
response = client.get(f"/api/portfolios/{portfolio_id}")
|
||||
assert response.status_code == 401
|
||||
122
backend/tests/e2e/test_snapshot_flow.py
Normal file
122
backend/tests/e2e/test_snapshot_flow.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""
|
||||
E2E tests for snapshot and returns flow.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_snapshot_requires_holdings(client: TestClient, auth_headers):
|
||||
"""Test that snapshot creation requires holdings."""
|
||||
# Create portfolio without holdings
|
||||
response = client.post(
|
||||
"/api/portfolios",
|
||||
json={"name": "Empty Portfolio", "portfolio_type": "general"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
portfolio_id = response.json()["id"]
|
||||
|
||||
# Try to create snapshot
|
||||
response = client.post(
|
||||
f"/api/portfolios/{portfolio_id}/snapshots",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "empty" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_snapshot_list_empty(client: TestClient, auth_headers):
|
||||
"""Test listing snapshots for portfolio with no snapshots."""
|
||||
# Create portfolio
|
||||
response = client.post(
|
||||
"/api/portfolios",
|
||||
json={"name": "Snapshot Test Portfolio", "portfolio_type": "general"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
portfolio_id = response.json()["id"]
|
||||
|
||||
# List snapshots
|
||||
response = client.get(
|
||||
f"/api/portfolios/{portfolio_id}/snapshots",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
def test_returns_empty(client: TestClient, auth_headers):
|
||||
"""Test returns for portfolio with no snapshots."""
|
||||
# Create portfolio
|
||||
response = client.post(
|
||||
"/api/portfolios",
|
||||
json={"name": "Returns Test Portfolio", "portfolio_type": "general"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
portfolio_id = response.json()["id"]
|
||||
|
||||
# Get returns
|
||||
response = client.get(
|
||||
f"/api/portfolios/{portfolio_id}/returns",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["portfolio_id"] == portfolio_id
|
||||
assert data["total_return"] is None
|
||||
assert data["data"] == []
|
||||
|
||||
|
||||
def test_snapshot_not_found(client: TestClient, auth_headers):
|
||||
"""Test getting non-existent snapshot."""
|
||||
# Create portfolio
|
||||
response = client.post(
|
||||
"/api/portfolios",
|
||||
json={"name": "Not Found Test Portfolio", "portfolio_type": "general"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
portfolio_id = response.json()["id"]
|
||||
|
||||
# Get non-existent snapshot
|
||||
response = client.get(
|
||||
f"/api/portfolios/{portfolio_id}/snapshots/99999",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_snapshot_delete_not_found(client: TestClient, auth_headers):
|
||||
"""Test deleting non-existent snapshot."""
|
||||
# Create portfolio
|
||||
response = client.post(
|
||||
"/api/portfolios",
|
||||
json={"name": "Delete Test Portfolio", "portfolio_type": "general"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
portfolio_id = response.json()["id"]
|
||||
|
||||
# Delete non-existent snapshot
|
||||
response = client.delete(
|
||||
f"/api/portfolios/{portfolio_id}/snapshots/99999",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_snapshot_requires_auth(client: TestClient, auth_headers):
|
||||
"""Test that snapshot endpoints require authentication."""
|
||||
# Create portfolio
|
||||
response = client.post(
|
||||
"/api/portfolios",
|
||||
json={"name": "Auth Test Portfolio", "portfolio_type": "general"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
portfolio_id = response.json()["id"]
|
||||
|
||||
# Try without auth
|
||||
response = client.get(f"/api/portfolios/{portfolio_id}/snapshots")
|
||||
assert response.status_code == 401
|
||||
|
||||
response = client.post(f"/api/portfolios/{portfolio_id}/snapshots")
|
||||
assert response.status_code == 401
|
||||
|
||||
response = client.get(f"/api/portfolios/{portfolio_id}/returns")
|
||||
assert response.status_code == 401
|
||||
86
backend/tests/e2e/test_strategy_flow.py
Normal file
86
backend/tests/e2e/test_strategy_flow.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""
|
||||
E2E tests for quant strategy flow.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_multi_factor_strategy(client: TestClient, auth_headers):
|
||||
"""Test multi-factor strategy endpoint."""
|
||||
response = client.post(
|
||||
"/api/strategy/multi-factor",
|
||||
json={
|
||||
"market": "KOSPI",
|
||||
"min_market_cap": 100000000000,
|
||||
"top_n": 20,
|
||||
"weights": {
|
||||
"value": 0.3,
|
||||
"quality": 0.3,
|
||||
"momentum": 0.2,
|
||||
"f_score": 0.2,
|
||||
},
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
# May fail if no stock data, just check it returns a proper response
|
||||
assert response.status_code in [200, 400, 500]
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
assert "stocks" in data
|
||||
assert "strategy_type" in data
|
||||
assert data["strategy_type"] == "multi_factor"
|
||||
|
||||
|
||||
def test_quality_strategy(client: TestClient, auth_headers):
|
||||
"""Test quality strategy endpoint."""
|
||||
response = client.post(
|
||||
"/api/strategy/quality",
|
||||
json={
|
||||
"market": "KOSPI",
|
||||
"min_market_cap": 100000000000,
|
||||
"top_n": 20,
|
||||
"min_f_score": 6,
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code in [200, 400, 500]
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
assert "stocks" in data
|
||||
assert data["strategy_type"] == "quality"
|
||||
|
||||
|
||||
def test_value_momentum_strategy(client: TestClient, auth_headers):
|
||||
"""Test value-momentum strategy endpoint."""
|
||||
response = client.post(
|
||||
"/api/strategy/value-momentum",
|
||||
json={
|
||||
"market": "KOSPI",
|
||||
"min_market_cap": 100000000000,
|
||||
"top_n": 20,
|
||||
"value_weight": 0.5,
|
||||
"momentum_weight": 0.5,
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code in [200, 400, 500]
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
assert "stocks" in data
|
||||
assert data["strategy_type"] == "value_momentum"
|
||||
|
||||
|
||||
def test_strategy_requires_auth(client: TestClient):
|
||||
"""Test that strategy endpoints require authentication."""
|
||||
response = client.post(
|
||||
"/api/strategy/multi-factor",
|
||||
json={
|
||||
"market": "KOSPI",
|
||||
"min_market_cap": 100000000000,
|
||||
"top_n": 20,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
34
frontend/e2e/auth.spec.ts
Normal file
34
frontend/e2e/auth.spec.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Authentication", () => {
|
||||
test("should show login page", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
await expect(page.locator("h1")).toContainText("로그인");
|
||||
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||
await expect(page.locator('input[name="password"]')).toBeVisible();
|
||||
await expect(page.locator('button[type="submit"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show error on invalid login", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
await page.fill('input[name="username"]', "invaliduser");
|
||||
await page.fill('input[name="password"]', "invalidpassword");
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for error message
|
||||
await expect(page.locator(".text-red-700, .text-red-600")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
test("should redirect to login when accessing protected page", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/portfolio");
|
||||
|
||||
// Should redirect to login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
77
frontend/e2e/backtest.spec.ts
Normal file
77
frontend/e2e/backtest.spec.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Backtest", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock login
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("token", "test-token");
|
||||
});
|
||||
});
|
||||
|
||||
test("should show backtest page", async ({ page }) => {
|
||||
await page.goto("/backtest");
|
||||
|
||||
// Check page title
|
||||
await expect(page.locator("h1")).toContainText("백테스트");
|
||||
});
|
||||
|
||||
test("should show backtest form", async ({ page }) => {
|
||||
await page.goto("/backtest");
|
||||
|
||||
// Check for strategy selection
|
||||
await expect(
|
||||
page.locator('select, [role="combobox"], input[type="radio"]').first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show date inputs", async ({ page }) => {
|
||||
await page.goto("/backtest");
|
||||
|
||||
// Check for date inputs
|
||||
await expect(
|
||||
page.locator('input[type="date"], input[placeholder*="날짜"]').first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show initial capital input", async ({ page }) => {
|
||||
await page.goto("/backtest");
|
||||
|
||||
// Check for capital input
|
||||
await expect(
|
||||
page
|
||||
.locator('input[name*="capital"], input[placeholder*="자본"]')
|
||||
.first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show rebalance period selection", async ({ page }) => {
|
||||
await page.goto("/backtest");
|
||||
|
||||
// Check for period selection
|
||||
const periodText = await page.locator(
|
||||
'text=월별, text=분기별, text=리밸런싱, select'
|
||||
);
|
||||
await expect(periodText.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have submit button", async ({ page }) => {
|
||||
await page.goto("/backtest");
|
||||
|
||||
// Check for submit button
|
||||
await expect(
|
||||
page
|
||||
.locator(
|
||||
'button[type="submit"], button:has-text("실행"), button:has-text("시작")'
|
||||
)
|
||||
.first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should navigate to backtest result page", async ({ page }) => {
|
||||
// Assuming backtest with ID 1 exists
|
||||
await page.goto("/backtest/1");
|
||||
|
||||
// Should show result page or error
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
66
frontend/e2e/portfolio.spec.ts
Normal file
66
frontend/e2e/portfolio.spec.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Helper to login before tests
|
||||
async function login(page: import("@playwright/test").Page) {
|
||||
await page.goto("/login");
|
||||
await page.fill('input[name="username"]', "testuser");
|
||||
await page.fill('input[name="password"]', "testpassword");
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for redirect to dashboard or portfolio page
|
||||
await page.waitForURL(/\/(portfolio)?$/);
|
||||
}
|
||||
|
||||
test.describe("Portfolio", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock login - in real tests, use proper authentication
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("token", "test-token");
|
||||
});
|
||||
});
|
||||
|
||||
test("should show portfolio list page", async ({ page }) => {
|
||||
await page.goto("/portfolio");
|
||||
|
||||
// Check page title
|
||||
await expect(page.locator("h1")).toContainText("포트폴리오");
|
||||
});
|
||||
|
||||
test("should have create portfolio button", async ({ page }) => {
|
||||
await page.goto("/portfolio");
|
||||
|
||||
// Look for create button or link
|
||||
const createButton = page.locator('a[href="/portfolio/new"], button:has-text("생성"), button:has-text("새")');
|
||||
await expect(createButton.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("should navigate to new portfolio page", async ({ page }) => {
|
||||
await page.goto("/portfolio/new");
|
||||
|
||||
// Check for form elements
|
||||
await expect(page.locator('input[name="name"], input[placeholder*="이름"]').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("should navigate to portfolio detail page", async ({ page }) => {
|
||||
// Assuming portfolio with ID 1 exists
|
||||
await page.goto("/portfolio/1");
|
||||
|
||||
// Should show portfolio content or redirect
|
||||
// The exact behavior depends on whether the portfolio exists
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should navigate to history page", async ({ page }) => {
|
||||
await page.goto("/portfolio/1/history");
|
||||
|
||||
// Should show history page or error
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should navigate to rebalance page", async ({ page }) => {
|
||||
await page.goto("/portfolio/1/rebalance");
|
||||
|
||||
// Should show rebalance page or error
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
65
frontend/e2e/strategy.spec.ts
Normal file
65
frontend/e2e/strategy.spec.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Strategy", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock login
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("token", "test-token");
|
||||
});
|
||||
});
|
||||
|
||||
test("should show strategy list page", async ({ page }) => {
|
||||
await page.goto("/strategy");
|
||||
|
||||
// Check page title
|
||||
await expect(page.locator("h1")).toContainText("전략");
|
||||
});
|
||||
|
||||
test("should show multi-factor strategy option", async ({ page }) => {
|
||||
await page.goto("/strategy");
|
||||
|
||||
// Look for multi-factor strategy card or link
|
||||
await expect(
|
||||
page.locator('text=멀티 팩터, a[href*="multi-factor"]').first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show quality strategy option", async ({ page }) => {
|
||||
await page.goto("/strategy");
|
||||
|
||||
// Look for quality strategy card or link
|
||||
await expect(
|
||||
page.locator('text=퀄리티, a[href*="quality"]').first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show value-momentum strategy option", async ({ page }) => {
|
||||
await page.goto("/strategy");
|
||||
|
||||
// Look for value-momentum strategy card or link
|
||||
await expect(
|
||||
page.locator('text=밸류 모멘텀, a[href*="value-momentum"]').first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should navigate to multi-factor strategy page", async ({ page }) => {
|
||||
await page.goto("/strategy/multi-factor");
|
||||
|
||||
// Check for form elements
|
||||
await expect(page.locator("form, [role='form']").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("should navigate to quality strategy page", async ({ page }) => {
|
||||
await page.goto("/strategy/quality");
|
||||
|
||||
// Check for form elements
|
||||
await expect(page.locator("form, [role='form']").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("should navigate to value-momentum strategy page", async ({ page }) => {
|
||||
await page.goto("/strategy/value-momentum");
|
||||
|
||||
// Check for form elements
|
||||
await expect(page.locator("form, [role='form']").first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
26
frontend/playwright.config.ts
Normal file
26
frontend/playwright.config.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user