diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile index f9a4a84e..d86cad10 100644 --- a/src/backend/Dockerfile +++ b/src/backend/Dockerfile @@ -60,6 +60,10 @@ RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync +# Install the test dependencies using uv +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --group test + # Run stage (final stage) FROM python:$PYTHON_BASE AS service diff --git a/src/backend/app/models/enums.py b/src/backend/app/models/enums.py index 7a021c15..e2b79093 100644 --- a/src/backend/app/models/enums.py +++ b/src/backend/app/models/enums.py @@ -15,7 +15,7 @@ class IntEnum(int, Enum): pass -class FinalOutput(Enum): +class FinalOutput(str, Enum): ORTHOPHOTO_2D = "ORTHOPHOTO_2D" ORTHOPHOTO_3D = "ORTHOPHOTO_3D" DIGITAL_TERRAIN_MODEL = "DIGITAL_TERRAIN_MODEL" diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index 02297c2e..962b2f4c 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -9,7 +9,6 @@ computed_field, Field, model_validator, - root_validator, EmailStr, ) from pydantic.functional_validators import AfterValidator @@ -110,7 +109,7 @@ class ProjectIn(BaseModel): ) final_output: List[FinalOutput] = Field( ..., - example=[ + json_schema_extra=[ "ORTHOPHOTO_2D", "ORTHOPHOTO_3D", "DIGITAL_TERRAIN_MODEL", @@ -538,7 +537,7 @@ class Pagination(BaseModel): per_page: int total: int - @root_validator(pre=True) + @model_validator(mode="before") def calculate_pagination(cls, values): page = values.get("page", 1) total = values.get("total", 1) diff --git a/src/backend/app/tasks/task_logic.py b/src/backend/app/tasks/task_logic.py index c9e09199..e7277dec 100644 --- a/src/backend/app/tasks/task_logic.py +++ b/src/backend/app/tasks/task_logic.py @@ -42,19 +42,22 @@ async def get_task_stats(db: Connection, user_data: AuthUser): WHERE ( %(role)s = 'DRONE_PILOT' - AND te.user_id = %(user_id)s + AND te.user_id = %(user_id)s AND te.state NOT IN ('UNLOCKED_TO_MAP') ) OR ( - %(role)s = 'PROJECT_CREATOR' - AND ( - te.project_id IN ( - SELECT p.id - FROM projects p - WHERE p.author_id = %(user_id)s + %(role)s = 'PROJECT_CREATOR' + AND ( + te.user_id = %(user_id)s AND te.state NOT IN ('REQUEST_FOR_MAPPING') + OR + te.project_id IN ( + SELECT p.id + FROM projects p + WHERE + p.author_id = %(user_id)s + ) ) - OR te.user_id = %(user_id)s -- Grant permissions equivalent to DRONE_PILOT - )) + ) ORDER BY te.task_id, te.created_at DESC ) AS te; """ diff --git a/src/backend/app/tasks/task_schemas.py b/src/backend/app/tasks/task_schemas.py index 2e1c5210..31715c2e 100644 --- a/src/backend/app/tasks/task_schemas.py +++ b/src/backend/app/tasks/task_schemas.py @@ -224,19 +224,26 @@ async def get_tasks_by_user( WHERE ( %(role)s = 'DRONE_PILOT' - AND task_events.user_id = %(user_id)s + AND task_events.user_id = %(user_id)s AND task_events.state NOT IN ('UNLOCKED_TO_MAP') ) OR ( - %(role)s = 'PROJECT_CREATOR' AND ( - task_events.project_id IN ( - SELECT p.id - FROM projects p - WHERE p.author_id = %(user_id)s + %(role)s = 'PROJECT_CREATOR' + AND ( + ( + task_events.user_id = %(user_id)s AND task_events.state NOT IN ('REQUEST_FOR_MAPPING') + ) + OR + ( + task_events.project_id IN ( + SELECT p.id + FROM projects p + WHERE + p.author_id = %(user_id)s + ) + ) ) - OR task_events.user_id = %(user_id)s ) - ) ORDER BY tasks.id, task_events.created_at DESC OFFSET %(skip)s diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 29fb28f9..a4b08bc2 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "bcrypt>=4.2.1", "drone-flightplan>=0.3.2", "Scrapy==2.12.0", + "asgi-lifespan>=2.1.0", ] requires-python = ">=3.11" license = {text = "GPL-3.0-only"} diff --git a/src/backend/tests/__init__.py b/src/backend/tests/__init__.py new file mode 100644 index 00000000..5581e5c3 --- /dev/null +++ b/src/backend/tests/__init__.py @@ -0,0 +1 @@ +"""Backend tests using PyTest.""" diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py new file mode 100644 index 00000000..5de711cb --- /dev/null +++ b/src/backend/tests/conftest.py @@ -0,0 +1,140 @@ +from typing import AsyncGenerator, Any +from app.db.database import get_db +from app.users.user_deps import login_required +from app.models.enums import UserRole +from fastapi import FastAPI +from app.main import get_application +from app.users.user_schemas import AuthUser +import pytest_asyncio +from app.config import settings +from asgi_lifespan import LifespanManager +from httpx import ASGITransport, AsyncClient +from psycopg import AsyncConnection +from app.users.user_schemas import DbUser +import pytest +from app.projects.project_schemas import ProjectIn, DbProject + + +@pytest_asyncio.fixture(scope="function") +async def db() -> AsyncConnection: + """The psycopg async database connection using psycopg3.""" + db_conn = await AsyncConnection.connect( + conninfo=settings.DTM_DB_URL.unicode_string(), + ) + try: + yield db_conn + finally: + await db_conn.close() + + +@pytest_asyncio.fixture(scope="function") +async def user(db) -> AuthUser: + """Create a test user.""" + db_user = await DbUser.get_or_create_user( + db, + AuthUser( + id="101039844375937810000", + email="admin@hotosm.org", + name="admin", + profile_img="", + role=UserRole.PROJECT_CREATOR, + ), + ) + return db_user + + +@pytest_asyncio.fixture(scope="function") +async def project_info(db, user): + """ + Fixture to create project metadata for testing. + + """ + print( + f"User passed to project_info fixture: {user}, ID: {getattr(user, 'id', 'No ID')}" + ) + + project_metadata = ProjectIn( + name="TEST 98982849249278787878778", + description="", + outline={ + "type": "FeatureCollection", + "features": [ + { + "id": "d10fbd780ecd3ff7851cb222467616a0", + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + [ + [-69.49779538720068, 18.629654277305633], + [-69.48497355306813, 18.616997544638636], + [-69.54053483430786, 18.608390428368665], + [-69.5410690773959, 18.614466085056165], + [-69.49779538720068, 18.629654277305633], + ] + ], + "type": "Polygon", + }, + } + ], + }, + no_fly_zones=None, + gsd_cm_px=1, + task_split_dimension=400, + is_terrain_follow=False, + per_task_instructions="", + deadline_at=None, + visibility=0, + requires_approval_from_manager_for_locking=False, + requires_approval_from_regulator=False, + front_overlap=1, + side_overlap=1, + final_output=["ORTHOPHOTO_2D"], + ) + + try: + await DbProject.create(db, project_metadata, getattr(user, "id", "")) + return project_metadata + except Exception as e: + pytest.fail(f"Fixture setup failed with exception: {str(e)}") + + +@pytest_asyncio.fixture(autouse=True) +async def app() -> AsyncGenerator[FastAPI, Any]: + """Get the FastAPI test server.""" + yield get_application() + + +@pytest_asyncio.fixture(scope="function") +def drone_info(): + """Test drone information.""" + return { + "model": "DJI Mavic-12344", + "manufacturer": "DJI", + "camera_model": "DJI Camera 1", + "sensor_width": 13.2, + "sensor_height": 8.9, + "max_battery_health": 0.85, + "focal_length": 24.0, + "image_width": 400, + "image_height": 300, + "max_altitude": 500.0, + "max_speed": 72.0, + "weight": 1.5, + } + + +@pytest_asyncio.fixture(scope="function") +async def client(app: FastAPI, db: AsyncConnection): + """The FastAPI test server.""" + # Override server db connection + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[login_required] = lambda: user + + async with LifespanManager(app) as manager: + async with AsyncClient( + transport=ASGITransport(app=manager.app), + base_url="http://test", + follow_redirects=True, + ) as ac: + yield ac diff --git a/src/backend/tests/test_drones_routes.py b/src/backend/tests/test_drones_routes.py new file mode 100644 index 00000000..94b44f09 --- /dev/null +++ b/src/backend/tests/test_drones_routes.py @@ -0,0 +1,30 @@ +from app.models.enums import HTTPStatus +import pytest + + +@pytest.mark.asyncio +async def test_create_drone(client, drone_info): + """Create a new project.""" + + response = await client.post("/api/drones/create-drone", json=drone_info) + assert response.status_code == HTTPStatus.OK + + return response.json() + + +@pytest.mark.asyncio +async def test_read_drone(client, drone_info): + """Test retrieving a drone record.""" + + response = await client.post("/api/drones/create-drone", json=drone_info) + assert response.status_code == HTTPStatus.OK + drone_id = response.json().get("drone_id") + response = await client.get(f"/api/drones/{drone_id}") + assert response.status_code == HTTPStatus.OK + drone_data = response.json() + assert drone_data.get("model") == drone_info["model"] + + +if __name__ == "__main__": + """Main func if file invoked directly.""" + pytest.main() diff --git a/src/backend/tests/test_projects_routes.py b/src/backend/tests/test_projects_routes.py new file mode 100644 index 00000000..2d4ce665 --- /dev/null +++ b/src/backend/tests/test_projects_routes.py @@ -0,0 +1,27 @@ +# import pytest +# import json + + +# @pytest.mark.asyncio +# async def test_create_project_with_files(client, project_info,): +# """ +# Test to verify the project creation API with file upload (image as binary data). +# """ +# project_info_json = json.dumps(project_info.model_dump()) +# files = { +# "project_info": (None, project_info_json, "application/json"), +# "dem": None, +# "image": None +# } + +# files = {k: v for k, v in files.items() if v is not None} +# response = await client.post( +# "/api/projects/", +# files=files +# ) +# assert response.status_code == 201 +# return response.json() + +# if __name__ == "__main__": +# """Main func if file invoked directly.""" +# pytest.main() diff --git a/src/backend/tests/test_users_routes.py b/src/backend/tests/test_users_routes.py new file mode 100644 index 00000000..4154cac4 --- /dev/null +++ b/src/backend/tests/test_users_routes.py @@ -0,0 +1,36 @@ +import pytest +from app.config import settings +import jwt +import pytest_asyncio +from datetime import datetime, timedelta +from loguru import logger as log + + +@pytest_asyncio.fixture(scope="function") +def token(user): + """ + Create a reset password token for a given user. + """ + payload = { + "sub": user.email_address, + "exp": datetime.utcnow() + + timedelta(minutes=settings.RESET_PASSWORD_TOKEN_EXPIRE_MINUTES), + } + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +@pytest.mark.asyncio +async def test_reset_password_success(client, token): + """ + Test successful password reset using a valid token. + """ + new_password = "QPassword@12334" + + response = await client.post( + f"/api/users/reset-password?token={token}&new_password={new_password}" + ) + + if response.status_code != 200: + log.debug("Response:", response.status_code, response.json()) + + assert response.status_code == 200 diff --git a/src/backend/uv.lock b/src/backend/uv.lock index 693024a9..6b2ab52b 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -89,6 +89,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/e4/bf8034d25edaa495da3c8a3405627d2e35758e44ff6eaa7948092646fdcc/argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93", size = 53104 }, ] +[[package]] +name = "asgi-lifespan" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/da/e7908b54e0f8043725a990bf625f2041ecf6bfe8eb7b19407f1c00b630f7/asgi-lifespan-2.1.0.tar.gz", hash = "sha256:5e2effaf0bfe39829cf2d64e7ecc47c7d86d676a6599f7afba378c31f5e3a308", size = 15627 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895 }, +] + [[package]] name = "asgiref" version = "3.8.1" @@ -399,7 +411,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 }, { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 }, { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 }, - { url = "https://files.pythonhosted.org/packages/4e/d5/9cc182bf24c86f542129565976c21301d4ac397e74bf5a16e48241aab8a6/cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385", size = 4164756 }, { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 }, { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 }, { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 }, @@ -410,7 +421,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 }, { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 }, { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 }, - { url = "https://files.pythonhosted.org/packages/31/d9/90409720277f88eb3ab72f9a32bfa54acdd97e94225df699e7713e850bd4/cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba", size = 4165207 }, { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 }, { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 }, { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 }, @@ -516,6 +526,7 @@ source = { virtual = "." } dependencies = [ { name = "aiosmtplib" }, { name = "alembic" }, + { name = "asgi-lifespan" }, { name = "asgiref" }, { name = "bcrypt" }, { name = "drone-flightplan" }, @@ -582,6 +593,7 @@ test = [ requires-dist = [ { name = "aiosmtplib", specifier = ">=3.0.1" }, { name = "alembic", specifier = ">=1.13.1" }, + { name = "asgi-lifespan", specifier = ">=2.1.0" }, { name = "asgiref", specifier = ">=3.8.1" }, { name = "bcrypt", specifier = ">=4.2.1" }, { name = "drone-flightplan", specifier = ">=0.3.2" },