diff --git a/app/clickwrap/data/postgres/db_repo.py b/app/clickwrap/data/postgres/db_repo.py new file mode 100644 index 0000000..6bcf27a --- /dev/null +++ b/app/clickwrap/data/postgres/db_repo.py @@ -0,0 +1,64 @@ +import logging + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.clickwrap.domain.domain_models import ( + PacketDomainModel, + PacketFilterRequest, + PacketListDomainModel, +) +from app.db.models import Packet + +logger = logging.getLogger(__name__) + + +class PacketDBRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def filter(self, request: PacketFilterRequest) -> PacketListDomainModel: + """ + Returns packets for the given workspace, ordered by id descending + (most recently created first). + + Workspace isolation is always applied — request.workspace_id is a + required field and is always included in the WHERE clause. + + Soft-delete: starts from Packet.objects() (excludes deleted) by default. + Pass include_deleted=True to use Packet.objects_including_deleted() instead. + + Args: + request.workspace_id: Required. Scopes results to this workspace. + request.packet_ids: Optional. Further filters to only these IDs. + request.include_deleted: When True, includes soft-deleted packets. + """ + base = ( + Packet.objects_including_deleted() + if request.include_deleted + else Packet.objects() + ) + + query = ( + base.where(Packet.workspace_id == request.workspace_id) + .order_by(Packet.id.desc()) + ) + + if request.packet_ids is not None: + query = query.where(Packet.id.in_(request.packet_ids)) + + result = await self._session.execute(query) + packets = result.scalars().all() + + logger.info( + "PacketDBRepository.filter completed", + extra={ + "workspace_id": request.workspace_id, + "packet_count": len(packets), + "filtered_by_ids": request.packet_ids is not None, + "include_deleted": request.include_deleted, + }, + ) + + return PacketListDomainModel( + items=[PacketDomainModel.model_validate(p) for p in packets] + ) diff --git a/app/clickwrap/domain/domain_models.py b/app/clickwrap/domain/domain_models.py new file mode 100644 index 0000000..7ad6196 --- /dev/null +++ b/app/clickwrap/domain/domain_models.py @@ -0,0 +1,38 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class PacketDomainModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + workspace_id: int + + name: str + name_slug: str + description: str | None + public_id: uuid.UUID + packet_settings_id: int + + created_by_org_user_id: int + updated_by_org_user_id: int | None + updated_by_org_user_at: datetime | None + + created_at: datetime + updated_at: datetime + + is_deleted: bool + deleted_at: datetime | None + deleted_by_org_user_id: int | None + + +class PacketListDomainModel(BaseModel): + items: list[PacketDomainModel] + + +class PacketFilterRequest(BaseModel): + workspace_id: int + packet_ids: list[int] | None = None + include_deleted: bool = False diff --git a/app/db/postgres.py b/app/db/postgres.py index f299064..88f5021 100644 --- a/app/db/postgres.py +++ b/app/db/postgres.py @@ -1,9 +1,13 @@ from datetime import datetime +from typing import TYPE_CHECKING -from sqlalchemy import BigInteger, Boolean, DateTime, func +from sqlalchemy import BigInteger, Boolean, DateTime, Select, func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +if TYPE_CHECKING: + from typing import Self + from app.core.config import settings # --------------------------------------------------------------------------- @@ -83,3 +87,13 @@ class SoftDeleteMixin: deleted_by_org_user_id: Mapped[int | None] = mapped_column( BigInteger, nullable=True ) + + @classmethod + def objects(cls) -> "Select[tuple[Self]]": + """SELECT excluding soft-deleted rows — the safe default for all queries.""" + return select(cls).where(cls.is_deleted.is_(False)) # type: ignore[attr-defined] + + @classmethod + def objects_including_deleted(cls) -> "Select[tuple[Self]]": + """SELECT including soft-deleted rows — opt-in when explicitly needed.""" + return select(cls) diff --git a/tests/base_db_repo_test.py b/tests/base_db_repo_test.py new file mode 100644 index 0000000..7b229ea --- /dev/null +++ b/tests/base_db_repo_test.py @@ -0,0 +1,18 @@ +""" +Base class for all DB repository tests. + +Mirrors SDTestCase in django-rest-api/core/testing.py. + +Usage +----- +Inherit from BaseDBRepoTestCase and declare the `db_session` fixture as a +method parameter. Each test method gets a fresh session with a rolled-back +transaction. # run everything +""" + +import pytest + + +@pytest.mark.integration +class BaseDBRepoTestCase: + pass diff --git a/tests/clickwrap/data/__init__.py b/tests/clickwrap/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/clickwrap/data/test_packet_db_repo.py b/tests/clickwrap/data/test_packet_db_repo.py new file mode 100644 index 0000000..477046c --- /dev/null +++ b/tests/clickwrap/data/test_packet_db_repo.py @@ -0,0 +1,93 @@ +from app.clickwrap.data.postgres.db_repo import PacketDBRepository +from app.clickwrap.domain.domain_models import ( + PacketDomainModel, + PacketFilterRequest, + PacketListDomainModel, +) +from tests.base_db_repo_test import BaseDBRepoTestCase +from tests.factories import PacketFactory + + +class TestPacketDBRepositoryFilter(BaseDBRepoTestCase): + async def test_filter_workspace_isolation(self, db_session): + """ + Each workspace sees only its own packets, ordered by id DESC. + + Creates packets in two workspaces and asserts that a filter scoped to + workspace A returns only workspace A's packets (newest first), and + workspace B's packets are invisible to it. + """ + # setup + ws_a, ws_b = 100, 101 + packet_a1 = await PacketFactory.create(db_session, workspace_id=ws_a) + packet_a2 = await PacketFactory.create(db_session, workspace_id=ws_a) + await PacketFactory.create(db_session, workspace_id=ws_b) # must not appear + + repo = PacketDBRepository(db_session) + + # make the call + result = await repo.filter(PacketFilterRequest(workspace_id=ws_a)) + + # assert — workspace isolation + id DESC ordering + assert result == PacketListDomainModel( + items=[ + PacketDomainModel.model_validate(packet_a2), + PacketDomainModel.model_validate(packet_a1), + ] + ) + + async def test_filter_by_packet_ids(self, db_session): + """ + When packet_ids is provided, only those IDs are returned. + Cross-workspace IDs are silently excluded (workspace isolation still + applies even when an explicit ID list is passed). + """ + # setup + ws_a, ws_b = 200, 201 + packet_a1 = await PacketFactory.create(db_session, workspace_id=ws_a) + packet_a2 = await PacketFactory.create(db_session, workspace_id=ws_a) + packet_b = await PacketFactory.create(db_session, workspace_id=ws_b) + + repo = PacketDBRepository(db_session) + + # only packet_a1 requested + result = await repo.filter( + PacketFilterRequest(workspace_id=ws_a, packet_ids=[packet_a1.id]) + ) + assert len(result.items) == 1 + assert result.items[0].id == packet_a1.id + + # packet_a2 + cross-workspace packet_b — packet_b must be excluded + result = await repo.filter( + PacketFilterRequest(workspace_id=ws_a, packet_ids=[packet_a2.id, packet_b.id]) + ) + assert len(result.items) == 1 + assert result.items[0].id == packet_a2.id + + async def test_filter_soft_delete_behaviour(self, db_session): + """ + Soft-deleted packets are excluded by default (Packet.objects()). + Passing include_deleted=True switches to Packet.objects_including_deleted() + and returns all packets regardless of deletion status. + """ + # setup + workspace_id = 300 + active = await PacketFactory.create(db_session, workspace_id=workspace_id) + deleted = await PacketFactory.create( + db_session, workspace_id=workspace_id, is_deleted=True + ) + + repo = PacketDBRepository(db_session) + + # default — deleted packet must not appear + result = await repo.filter(PacketFilterRequest(workspace_id=workspace_id)) + assert len(result.items) == 1 + assert result.items[0].id == active.id + + # opt-in — both packets returned + result = await repo.filter( + PacketFilterRequest(workspace_id=workspace_id, include_deleted=True) + ) + result_ids = {item.id for item in result.items} + assert active.id in result_ids + assert deleted.id in result_ids diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..049586f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,62 @@ + +import os + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.pool import NullPool + +import app.db.models # noqa: F401 — side-effect import: registers all ORM models with Base.metadata +from app.core.config import settings +from app.db.postgres import Base + +# --------------------------------------------------------------------------- +# Test DB URL resolution +# --------------------------------------------------------------------------- + + +def _test_database_url() -> str: + if explicit := os.environ.get("TEST_DATABASE_URL"): + return explicit + # Derive: postgresql+asyncpg://user:pw@host:port/tars → .../tars_test + return settings.DATABASE_URL + "_test" + + +TEST_DATABASE_URL = _test_database_url() + + +# --------------------------------------------------------------------------- +# Session-scoped engine — schema created once per pytest run +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +async def test_engine(): + engine = create_async_engine(TEST_DATABASE_URL, poolclass=NullPool, echo=False) + async with engine.begin() as conn: + # pg_trgm is required for the GIN trigram indexes defined on packet and + # agreement_version. Alembic enables it in the migration; we do the + # same here since tests bypass Alembic entirely. + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) + await conn.run_sync(Base.metadata.create_all) + yield engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + + +# --------------------------------------------------------------------------- +# Function-scoped session — rolled back after every test +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def db_session(test_engine) -> AsyncSession: # type: ignore[misc] + async with test_engine.connect() as connection: + await connection.begin() + session = AsyncSession(bind=connection, expire_on_commit=False, autoflush=False) + try: + yield session + finally: + await session.close() + await connection.rollback() diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 0000000..fbac392 --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,63 @@ + +import itertools +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.enums import AgreementUiType +from app.db.models import Packet, PacketSettings + +_counter = itertools.count(1) + + +def _seq() -> int: + return next(_counter) + +class PacketSettingsFactory: + @classmethod + async def create(cls, session: AsyncSession, **kwargs: object) -> PacketSettings: + n = _seq() + obj = PacketSettings( + workspace_id=kwargs.get("workspace_id", n), + created_by_org_user_id=kwargs.get("created_by_org_user_id", n), + agreement_ui_type=kwargs.get("agreement_ui_type", AgreementUiType.SINGLE_CHECKBOX), + clickwrap_texts=kwargs.get("clickwrap_texts", [{"text": "I agree to the terms."}]), + whitelisted_domains=kwargs.get("whitelisted_domains", []), + show_audit_click_status=kwargs.get("show_audit_click_status", False), + send_executed_audit_email=kwargs.get("send_executed_audit_email", False), + allow_all_domains=kwargs.get("allow_all_domains", False), + is_deleted=kwargs.get("is_deleted", False), + ) + session.add(obj) + await session.flush() + await session.refresh(obj) + return obj + +class PacketFactory: + @classmethod + async def create(cls, session: AsyncSession, **kwargs: object) -> Packet: + n = _seq() + workspace_id: int = int(kwargs.get("workspace_id", n)) # type: ignore[arg-type] + + # SubFactory equivalent: create PacketSettings unless caller supplied one. + packet_settings = kwargs.get("packet_settings") + if packet_settings is None: + packet_settings = await PacketSettingsFactory.create( + session, workspace_id=workspace_id + ) + + obj = Packet( + workspace_id=workspace_id, + created_by_org_user_id=kwargs.get("created_by_org_user_id", n), + updated_by_org_user_id=kwargs.get("updated_by_org_user_id"), + name=kwargs.get("name", f"Packet-{n}"), + name_slug=kwargs.get("name_slug", f"packet-{n}"), + description=kwargs.get("description"), + public_id=kwargs.get("public_id", uuid.uuid4()), + packet_settings_id=packet_settings.id, + is_deleted=kwargs.get("is_deleted", False), + ) + session.add(obj) + await session.flush() + await session.refresh(obj) + return obj