This commit is contained in:
2025-10-04 19:26:11 +02:00
parent 490245835d
commit ae6414c18b
16 changed files with 411 additions and 93 deletions

View File

@@ -1,5 +1,7 @@
from fastapi import FastAPI
from src.resources.routes import resources_router
app = FastAPI()
from src.users.router import users_router
@@ -7,3 +9,4 @@ from src.maps.router import maps_router
app.include_router(users_router, prefix="/users")
app.include_router(maps_router, prefix="/maps")
app.include_router(resources_router, prefix="/resources")

View File

@@ -1,19 +1,19 @@
"""empty message
Revision ID: 2607b8f9586f
Revision ID: 24a18ed1c824
Revises:
Create Date: 2025-10-04 15:15:16.698698
Create Date: 2025-10-04 18:51:07.316957
"""
from typing import Sequence, Union
import sqlmodel
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '2607b8f9586f'
revision: str = '24a18ed1c824'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -30,10 +30,10 @@ def upgrade() -> None:
)
op.create_index(op.f('ix_user_name'), 'user', ['name'], unique=True)
op.create_table('waypoint',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('x', sa.Float(), nullable=False),
sa.Column('y', sa.Float(), nullable=False),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###

View File

@@ -1,16 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from sqlmodel import select
from src.db import get_db
from src.maps.models import Waypoint
from src.maps.schemas import WaypointCreate, WaypointResponse
from src.maps.schemas import WaypointCreate, WaypointResponse, WaypointPatch
from src.utils.decorators import auth_required
maps_router = APIRouter()
@auth_required()
@maps_router.post("/waypoints", response_model=WaypointResponse)
def create_waypoint(waypoint: WaypointCreate, db: Session = Depends(get_db)):
@@ -35,3 +35,30 @@ def get_waypoint(waypoint_id: int, db: Session = Depends(get_db)):
if not wp:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waypoint not found")
return wp
@auth_required()
@maps_router.patch("/waypoints/{waypoint_id}", response_model=WaypointResponse)
def update_waypoint(waypoint_id: int, waypoint_data: WaypointPatch, db: Session = Depends(get_db)):
wp = db.get(Waypoint, waypoint_id)
if not wp:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waypoint not found")
for key, value in waypoint_data.model_dump(exclude_unset=True).items():
setattr(wp, key, value)
db.add(wp)
db.commit()
db.refresh(wp)
return wp
@auth_required()
@maps_router.delete("/waypoints/{waypoint_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_waypoint(waypoint_id: int, db: Session = Depends(get_db)):
wp = db.get(Waypoint, waypoint_id)
if not wp:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waypoint not found")
db.delete(wp)
db.commit()
return

View File

@@ -1,3 +1,5 @@
from typing import Optional
from pydantic import BaseModel, ConfigDict
@@ -6,6 +8,12 @@ class WaypointCreate(BaseModel):
x: float
y: float
class WaypointPatch(BaseModel):
name: Optional[str] = None
x: Optional[float] = None
y: Optional[float] = None
class WaypointResponse(WaypointCreate):
id: int

View File

@@ -1,85 +0,0 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import StaticPool
from sqlmodel import SQLModel, create_engine, Session
from sqlalchemy.orm import sessionmaker
from src import app, maps_router
from src.maps.models import Waypoint
from src.db import get_db
# --- Setup in-memory SQLite ---
TEST_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool # <-- crucial for in-memory DB
)
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
# --- Override dependency ---
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
# Include router (no need to check prefix)
app.include_router(maps_router, prefix="/maps")
client = TestClient(app)
# --- Fixture to create tables ---
@pytest.fixture(scope="module", autouse=True)
def setup_db():
SQLModel.metadata.create_all(engine)
yield
SQLModel.metadata.drop_all(engine)
# --- Bypass auth decorator for tests ---
def fake_auth_dependency():
return lambda: True
# Monkeypatch your auth_required to do nothing in tests
from src.utils.decorators import auth_required
auth_required = lambda *args, **kwargs: (lambda x: x)
# --- Tests ---
def test_create_waypoint():
payload = {"name": "TestPoint", "x": 10.5, "y": 20.5}
response = client.post("/maps/waypoints", json=payload)
assert response.status_code == 200
data = response.json()
assert data["name"] == payload["name"]
assert data["x"] == payload["x"]
assert data["y"] == payload["y"]
assert "id" in data
def test_get_waypoints():
response = client.get("/maps/waypoints")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1
def test_get_waypoint_by_id():
# Create waypoint first
payload = {"name": "Point1", "x": 1.0, "y": 2.0}
create_resp = client.post("/maps/waypoints", json=payload)
wp_id = create_resp.json()["id"]
# Fetch by ID
response = client.get(f"/maps/waypoints/{wp_id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == wp_id
assert data["name"] == payload["name"]
def test_get_waypoint_not_found():
response = client.get("/maps/waypoints/9999")
assert response.status_code == 404
data = response.json()
assert data["detail"] == "Waypoint not found"

View File

21
src/resources/models.py Normal file
View File

@@ -0,0 +1,21 @@
from sqlmodel import SQLModel, Field, Relationship
from datetime import datetime
from typing import Optional, List
class ResourceType(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
changes: List["ResourceChange"] = Relationship(back_populates="type")
@property
def balance(self) -> float:
return sum(change.change for change in self.changes)
class ResourceChange(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
change: float
timestamp: datetime = Field(default_factory=datetime.now)
type_id: Optional[int] = Field(default=None, foreign_key="resourcetype.id")
type: Optional[ResourceType] = Relationship(back_populates="changes")

76
src/resources/routes.py Normal file
View File

@@ -0,0 +1,76 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from typing import List
from sqlmodel import select
from src.db import get_db
from src.utils.decorators import auth_required
from src.resources.models import ResourceType, ResourceChange
from src.resources.schemas import (
ResourceTypeCreate, ResourceTypeResponse,
ResourceChangeCreate, ResourceChangeResponse
)
resources_router = APIRouter()
@auth_required()
@resources_router.post("/types", response_model=ResourceTypeResponse)
def create_resource_type(rt: ResourceTypeCreate, db: Session = Depends(get_db)):
db_rt = ResourceType.model_validate(rt)
db.add(db_rt)
db.commit()
db.refresh(db_rt)
return db_rt
@auth_required()
@resources_router.get("/types", response_model=List[ResourceTypeResponse])
def list_resource_types(db: Session = Depends(get_db)):
rts = db.execute(select(ResourceType)).scalars().all()
return rts
@auth_required()
@resources_router.get("/types/{type_id}", response_model=ResourceTypeResponse)
def get_resource_type(type_id: int, db: Session = Depends(get_db)):
rt = db.get(ResourceType, type_id)
if not rt:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource type not found")
return rt
@auth_required()
@resources_router.delete("/types/{type_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_resource_type(type_id: int, db: Session = Depends(get_db)):
rt = db.get(ResourceType, type_id)
if not rt:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource type not found")
db.delete(rt)
db.commit()
return
@auth_required()
@resources_router.post("/types/{type_id}/changes", response_model=ResourceChangeResponse)
def create_resource_change(type_id: int, change: ResourceChangeCreate, db: Session = Depends(get_db)):
rt = db.get(ResourceType, type_id)
if not rt:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource type not found")
db_change = ResourceChange.model_validate(change)
db_change.type = rt
db.add(db_change)
db.commit()
db.refresh(db_change)
return db_change
@auth_required()
@resources_router.get("/types/{type_id}/changes", response_model=List[ResourceChangeResponse])
def list_resource_changes(type_id: int, db: Session = Depends(get_db)):
rt = db.get(ResourceType, type_id)
if not rt:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource type not found")
return rt.changes

19
src/resources/schemas.py Normal file
View File

@@ -0,0 +1,19 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class ResourceTypeCreate(BaseModel):
name: str
class ResourceTypeResponse(ResourceTypeCreate):
id: int
balance: float
model_config = ConfigDict(from_attributes=True)
class ResourceChangeCreate(BaseModel):
change: float
class ResourceChangeResponse(ResourceChangeCreate):
id: int
timestamp: datetime
model_config = ConfigDict(from_attributes=True)

0
src/tests/__init__.py Normal file
View File

42
src/tests/conftest.py Normal file
View File

@@ -0,0 +1,42 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlmodel import SQLModel
from sqlalchemy.pool import StaticPool
from src import app
from src.db import get_db
TEST_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool
)
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
@pytest.fixture(scope="session", autouse=True)
def setup_db():
SQLModel.metadata.create_all(engine)
yield
SQLModel.metadata.drop_all(engine)
@pytest.fixture(scope="session")
def db_session():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
@pytest.fixture(scope="session")
def client(db_session):
def override_get_db():
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
return TestClient(app)

75
src/tests/test_maps.py Normal file
View File

@@ -0,0 +1,75 @@
def fake_auth_dependency():
return lambda: True
auth_required = lambda *args, **kwargs: (lambda x: x)
def test_create_waypoint(client):
payload = {"name": "TestPoint", "x": 10.5, "y": 20.5}
response = client.post("/maps/waypoints", json=payload)
assert response.status_code == 200
data = response.json()
assert data["name"] == payload["name"]
assert data["x"] == payload["x"]
assert data["y"] == payload["y"]
assert "id" in data
def test_get_waypoints(client):
response = client.get("/maps/waypoints")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1
def test_get_waypoint_by_id(client):
payload = {"name": "Point1", "x": 1.0, "y": 2.0}
create_resp = client.post("/maps/waypoints", json=payload)
wp_id = create_resp.json()["id"]
response = client.get(f"/maps/waypoints/{wp_id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == wp_id
assert data["name"] == payload["name"]
def test_get_waypoint_not_found(client):
response = client.get("/maps/waypoints/9999")
assert response.status_code == 404
data = response.json()
assert data["detail"] == "Waypoint not found"
def test_delete_waypoint(client):
payload = {"name": "DeletePoint", "x": 5.0, "y": 5.0}
create_resp = client.post("/maps/waypoints", json=payload)
wp_id = create_resp.json()["id"]
del_resp = client.delete(f"/maps/waypoints/{wp_id}")
assert del_resp.status_code == 204
get_resp = client.get(f"/maps/waypoints/{wp_id}")
assert get_resp.status_code == 404
assert get_resp.json()["detail"] == "Waypoint not found"
def test_delete_waypoint_not_found(client):
response = client.delete("/maps/waypoints/9999")
assert response.status_code == 404
assert response.json()["detail"] == "Waypoint not found"
def test_patch_waypoint(client):
payload = {"name": "PatchPoint", "x": 0.0, "y": 0.0}
create_resp = client.post("/maps/waypoints", json=payload)
wp_id = create_resp.json()["id"]
patch_payload = {"name": "UpdatedPoint", "x": 1.1, "y": 2.2}
patch_resp = client.patch(f"/maps/waypoints/{wp_id}", json=patch_payload)
assert patch_resp.status_code == 200
data = patch_resp.json()
assert data["id"] == wp_id
assert data["name"] == patch_payload["name"]
assert data["x"] == patch_payload["x"]
assert data["y"] == patch_payload["y"]
def test_patch_waypoint_not_found(client):
patch_payload = {"name": "NonExistent", "x": 1.0, "y": 2.0}
response = client.patch("/maps/waypoints/9999", json=patch_payload)
assert response.status_code == 404
assert response.json()["detail"] == "Waypoint not found"

126
src/tests/test_resources.py Normal file
View File

@@ -0,0 +1,126 @@
def fake_auth_dependency():
return lambda: True
auth_required = lambda *args, **kwargs: (lambda x: x)
def test_create_resource_type(client):
payload = {"name": "Food"}
response = client.post("/resources/types", json=payload)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Food"
assert "id" in data
def test_list_resource_types(client):
response = client.get("/resources/types")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1
def test_get_resource_type_by_id(client):
payload = {"name": "Water"}
create_resp = client.post("/resources/types", json=payload)
type_id = create_resp.json()["id"]
response = client.get(f"/resources/types/{type_id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == type_id
assert data["name"] == "Water"
def test_get_resource_type_not_found(client):
response = client.get("/resources/types/9999")
assert response.status_code == 404
assert response.json()["detail"] == "Resource type not found"
def test_delete_resource_type(client):
payload = {"name": "Ammo"}
create_resp = client.post("/resources/types", json=payload)
type_id = create_resp.json()["id"]
del_resp = client.delete(f"/resources/types/{type_id}")
assert del_resp.status_code == 204
get_resp = client.get(f"/resources/types/{type_id}")
assert get_resp.status_code == 404
assert get_resp.json()["detail"] == "Resource type not found"
def test_delete_resource_type_not_found(client):
response = client.delete("/resources/types/9999")
assert response.status_code == 404
assert response.json()["detail"] == "Resource type not found"
# -------- Resource Change Tests --------
def test_create_resource_change(client):
# First create a resource type
type_resp = client.post("/resources/types", json={"name": "Medicine"})
type_id = type_resp.json()["id"]
payload = {"change": 10.5}
response = client.post(f"/resources/types/{type_id}/changes", json=payload)
assert response.status_code == 200
data = response.json()
assert data["change"] == 10.5
assert "timestamp" in data
assert "id" in data
def test_create_resource_change_type_not_found(client):
payload = {"change": -5}
response = client.post("/resources/types/9999/changes", json=payload)
assert response.status_code == 404
assert response.json()["detail"] == "Resource type not found"
def test_list_resource_changes(client):
# Create resource type with changes
type_resp = client.post("/resources/types", json={"name": "Fuel"})
type_id = type_resp.json()["id"]
client.post(f"/resources/types/{type_id}/changes", json={"change": 20})
client.post(f"/resources/types/{type_id}/changes", json={"change": -5})
response = client.get(f"/resources/types/{type_id}/changes")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 2
assert data[0]["change"] == 20
assert data[1]["change"] == -5
def test_list_resource_changes_type_not_found(client):
response = client.get("/resources/types/9999/changes")
assert response.status_code == 404
assert response.json()["detail"] == "Resource type not found"
def test_balance_of_resource_type(client):
# Create type
type_resp = client.post("/resources/types", json={"name": "Supplies"})
type_id = type_resp.json()["id"]
# Add changes
client.post(f"/resources/types/{type_id}/changes", json={"change": 50})
client.post(f"/resources/types/{type_id}/changes", json={"change": -20})
client.post(f"/resources/types/{type_id}/changes", json={"change": 5})
# Get by ID with balance
resp = client.get(f"/resources/types/{type_id}")
assert resp.status_code == 200
data = resp.json()
assert data["balance"] == 35 # 50 - 20 + 5
def test_list_resource_types_with_balance(client):
# Create type
type_resp = client.post("/resources/types", json={"name": "Tools"})
type_id = type_resp.json()["id"]
# Add changes
client.post(f"/resources/types/{type_id}/changes", json={"change": 10})
client.post(f"/resources/types/{type_id}/changes", json={"change": 15})
# List all
resp = client.get("/resources/types")
print(resp.json())
assert resp.status_code == 200
data = resp.json()
balances = {rt["name"]: rt["balance"] for rt in data}
assert balances["Tools"] == 25

View File

@@ -13,7 +13,7 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
if "sub" in to_encode:
to_encode["sub"] = str(to_encode["sub"])
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
expire = datetime.datetime.now() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)