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

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

Binary file not shown.

View File

@@ -1,5 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from src.resources.routes import resources_router
app = FastAPI() app = FastAPI()
from src.users.router import users_router 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(users_router, prefix="/users")
app.include_router(maps_router, prefix="/maps") app.include_router(maps_router, prefix="/maps")
app.include_router(resources_router, prefix="/resources")

View File

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

View File

@@ -1,16 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List
from sqlmodel import select from sqlmodel import select
from src.db import get_db from src.db import get_db
from src.maps.models import Waypoint 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 from src.utils.decorators import auth_required
maps_router = APIRouter() maps_router = APIRouter()
@auth_required() @auth_required()
@maps_router.post("/waypoints", response_model=WaypointResponse) @maps_router.post("/waypoints", response_model=WaypointResponse)
def create_waypoint(waypoint: WaypointCreate, db: Session = Depends(get_db)): 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: if not wp:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waypoint not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waypoint not found")
return wp 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 from pydantic import BaseModel, ConfigDict
@@ -6,6 +8,12 @@ class WaypointCreate(BaseModel):
x: float x: float
y: float y: float
class WaypointPatch(BaseModel):
name: Optional[str] = None
x: Optional[float] = None
y: Optional[float] = None
class WaypointResponse(WaypointCreate): class WaypointResponse(WaypointCreate):
id: int 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: if "sub" in to_encode:
to_encode["sub"] = str(to_encode["sub"]) 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}) to_encode.update({"exp": expire})
token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)