Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions app/clickwrap/data/postgres/db_repo.py
Original file line number Diff line number Diff line change
@@ -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]
)
38 changes: 38 additions & 0 deletions app/clickwrap/domain/domain_models.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 15 additions & 1 deletion app/db/postgres.py
Original file line number Diff line number Diff line change
@@ -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

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
18 changes: 18 additions & 0 deletions tests/base_db_repo_test.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
93 changes: 93 additions & 0 deletions tests/clickwrap/data/test_packet_db_repo.py
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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()
63 changes: 63 additions & 0 deletions tests/factories.py
Original file line number Diff line number Diff line change
@@ -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