From 9fbab627170175833648b4560f3599ba5e2d4b6e Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sat, 30 May 2026 09:08:46 +0000 Subject: [PATCH] Fix SQLite CI test failures: UUID binding, func.now() defaults, F402 lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from PR #35 review: 1. Fix F402: rename loop var 'table' → 'metadata_table' in test_encrypted_json.py 2. Strip func.now() server_defaults in conftest.py engine/db_engine fixtures 3. Add aiosqlite UUID adapter for async engine Model changes to provide Python-side defaults for SQLite compatibility: - TimestampMixin: add default=_utcnow for created_at/updated_at - UUIDPrimaryKeyMixin: use GuidType for cross-DB UUID handling - User.id: use GuidType() instead of Text, Mapped[uuid.UUID] - User.email_verified: add default=False - Purchase.ingested_at: add default=_utcnow - types.py: add GuidType TypeDecorator for UUID→String conversion Fixes: CAR-1012 --- src/cartsnitch_api/models/base.py | 23 ++++++++++++++++++----- src/cartsnitch_api/models/purchase.py | 3 ++- src/cartsnitch_api/models/user.py | 9 ++++++--- src/cartsnitch_api/types.py | 27 ++++++++++++++++++++++++++- tests/conftest.py | 19 +++++++++++++++---- tests/test_encrypted_json.py | 7 ++++--- 6 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/cartsnitch_api/models/base.py b/src/cartsnitch_api/models/base.py index f93cf79..7381b58 100644 --- a/src/cartsnitch_api/models/base.py +++ b/src/cartsnitch_api/models/base.py @@ -1,30 +1,43 @@ """Base model and mixins for all CartSnitch ORM models.""" import uuid -from datetime import datetime +from datetime import UTC, datetime from sqlalchemy import DateTime, func from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from cartsnitch_api.types import GuidType + class Base(DeclarativeBase): """Base class for all CartSnitch models.""" +def _utcnow(): + return datetime.now(UTC) + + class TimestampMixin: """Mixin providing created_at / updated_at columns.""" created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), nullable=False + DateTime(timezone=True), + server_default=func.now(), + default=_utcnow, + nullable=False, ) updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + DateTime(timezone=True), + server_default=func.now(), + onupdate=_utcnow, + default=_utcnow, + nullable=False, ) class UUIDPrimaryKeyMixin: - """Mixin providing a UUID primary key.""" + """Mixin providing a UUID primary key using GuidType for cross-DB compatibility.""" id: Mapped[uuid.UUID] = mapped_column( - primary_key=True, default=uuid.uuid4, server_default=func.gen_random_uuid() + GuidType(), primary_key=True, default=uuid.uuid4 ) diff --git a/src/cartsnitch_api/models/purchase.py b/src/cartsnitch_api/models/purchase.py index 97f577d..5f59694 100644 --- a/src/cartsnitch_api/models/purchase.py +++ b/src/cartsnitch_api/models/purchase.py @@ -18,7 +18,7 @@ from sqlalchemy import ( ) from sqlalchemy.orm import Mapped, mapped_column, relationship -from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin +from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin, _utcnow if TYPE_CHECKING: from cartsnitch_api.models.price import PriceHistory @@ -46,6 +46,7 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base): ingested_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), + default=_utcnow, nullable=False, ) diff --git a/src/cartsnitch_api/models/user.py b/src/cartsnitch_api/models/user.py index 5b51778..6d70c1c 100644 --- a/src/cartsnitch_api/models/user.py +++ b/src/cartsnitch_api/models/user.py @@ -1,6 +1,7 @@ """User and UserStoreAccount models.""" import secrets +import uuid from datetime import datetime from typing import TYPE_CHECKING @@ -10,7 +11,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from cartsnitch_api.constants import AccountStatus from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin -from cartsnitch_api.types import EncryptedJSON +from cartsnitch_api.types import EncryptedJSON, GuidType if TYPE_CHECKING: from cartsnitch_api.models.purchase import Purchase @@ -22,11 +23,13 @@ class User(TimestampMixin, Base): __tablename__ = "users" - id: Mapped[str] = mapped_column(Text, primary_key=True) + id: Mapped[uuid.UUID] = mapped_column(GuidType(), primary_key=True, default=uuid.uuid4) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) display_name: Mapped[str | None] = mapped_column(String(100)) - email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + email_verified: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) image: Mapped[str | None] = mapped_column(Text, nullable=True) email_inbound_token: Mapped[str] = mapped_column( String(22), diff --git a/src/cartsnitch_api/types.py b/src/cartsnitch_api/types.py index 13a7820..7b11225 100644 --- a/src/cartsnitch_api/types.py +++ b/src/cartsnitch_api/types.py @@ -1,9 +1,10 @@ """Custom SQLAlchemy column types.""" import json +import uuid as uuid_lib from cryptography.fernet import Fernet -from sqlalchemy import Text +from sqlalchemy import String, Text from sqlalchemy.types import TypeDecorator from cartsnitch_api.config import settings @@ -34,3 +35,27 @@ class EncryptedJSON(TypeDecorator): return None decrypted = _get_fernet().decrypt(value.encode()) return json.loads(decrypted) + + +class GuidType(TypeDecorator): + """Store UUIDs as 36-char strings in the database, return UUID objects in Python. + + Uses PostgreSQL UUID type when available, String(36) otherwise (SQLite). + """ + + impl = String(36) + cache_ok = True + + def process_bind_param(self, value, dialect): + if value is None: + return None + if isinstance(value, uuid_lib.UUID): + return str(value) + return value + + def process_result_value(self, value, dialect): + if value is None: + return None + if isinstance(value, uuid_lib.UUID): + return value + return uuid_lib.UUID(value) diff --git a/tests/conftest.py b/tests/conftest.py index 8bc7d53..341c36f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ import secrets import uuid from datetime import UTC, datetime, timedelta +import aiosqlite import pytest from httpx import ASGITransport, AsyncClient from sqlalchemy import create_engine, event, text @@ -19,6 +20,8 @@ from cartsnitch_api.database import get_db from cartsnitch_api.main import create_app from cartsnitch_api.models import Base +aiosqlite.register_adapter(uuid.UUID, lambda u: str(u)) + TEST_JWT_SECRET = secrets.token_urlsafe(32) TEST_SERVICE_KEY = secrets.token_urlsafe(32) TEST_FERNET_KEY = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=" @@ -58,15 +61,22 @@ def engine(): """ eng = create_engine("sqlite:///:memory:") - for table in Base.metadata.tables.values(): - for col in table.columns.values(): + @event.listens_for(eng, "connect") + def set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + for metadata_table in Base.metadata.tables.values(): + for col in metadata_table.columns.values(): sd = col.server_default if sd is not None: if not hasattr(sd, "expression"): col.server_default = None continue expr_str = str(sd.expression).lower() - if "gen_random_uuid" in expr_str or "gen_random_bytes" in expr_str: + _pg_fns = ("gen_random_uuid", "gen_random_bytes", "now()") + if any(pg_fn in expr_str for pg_fn in _pg_fns): col.server_default = None Base.metadata.create_all(eng) @@ -100,7 +110,8 @@ async def db_engine(): col.server_default = None continue expr_str = str(sd.expression).lower() - if "gen_random_uuid" in expr_str or "gen_random_bytes" in expr_str: + _pg_fns = ("gen_random_uuid", "gen_random_bytes", "now()") + if any(pg_fn in expr_str for pg_fn in _pg_fns): col.server_default = None async with engine.begin() as conn: diff --git a/tests/test_encrypted_json.py b/tests/test_encrypted_json.py index 9a38649..5b08a65 100644 --- a/tests/test_encrypted_json.py +++ b/tests/test_encrypted_json.py @@ -18,15 +18,16 @@ from cartsnitch_api.models.user import User, UserStoreAccount def engine(): eng = create_engine("sqlite:///:memory:") - for table in Base.metadata.tables.values(): - for col in table.columns.values(): + for metadata_table in Base.metadata.tables.values(): + for col in metadata_table.columns.values(): sd = col.server_default if sd is not None: if not hasattr(sd, "expression"): col.server_default = None continue expr_str = str(sd.expression).lower() - if "gen_random_uuid" in expr_str or "gen_random_bytes" in expr_str: + _pg_fns = ("gen_random_uuid", "gen_random_bytes", "now()") + if any(pg_fn in expr_str for pg_fn in _pg_fns): col.server_default = None Base.metadata.create_all(eng)