Compare commits

...

6 Commits

Author SHA1 Message Date
Flea Flicker 9fbab62717 Fix SQLite CI test failures: UUID binding, func.now() defaults, F402 lint
CI / lint (pull_request) Failing after 4s
CI / typecheck (pull_request) Failing after 28s
CI / test (pull_request) Failing after 2m37s
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
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
2026-05-30 09:08:46 +00:00
Barcode Betty 41a887a73b Fix SQLite server_default AttributeError and pool_size errors
CI / lint (pull_request) Failing after 3s
CI / typecheck (pull_request) Failing after 18s
CI / test (pull_request) Failing after 1m30s
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
- Add hasattr(sd, 'expression') guard in engine fixtures to prevent
  AttributeError when iterating over server_default columns that use
  DefaultClause (which lacks .expression)
- Add _build_engine_kwargs() in database.py to conditionally apply
  pool_size/max_overflow only for non-SQLite database URLs
- Fixes test failures in conftest.py, test_encrypted_json.py

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-25 21:46:53 +00:00
Savannah Savings 1c42e4b0af Merge pull request 'Fix: strip PostgreSQL server_defaults from SQLite test fixtures' (#32) from betty/fix-email-inbound-token-tests into dev
CI / lint (push) Failing after 7s
CI / typecheck (push) Failing after 17s
CI / test (push) Failing after 18s
CI / build-and-push (push) Has been skipped
CI / deploy-uat (push) Has been skipped
CI / deploy-dev (push) Failing after 25s
Merge PR #32: Fix SQLite server_default stripping for test fixtures
2026-05-23 23:46:59 +00:00
Savannah Savings 280882f515 Merge pull request 'Fix test failures: email_inbound_token server_default for SQLite' (#29) from betty/fix-email-inbound-token-tests into dev
CI / test (push) Failing after 1m19s
CI / build-and-push (push) Has been skipped
CI / lint (push) Successful in 5s
CI / typecheck (push) Failing after 29s
CI / deploy-uat (push) Has been skipped
CI / deploy-dev (push) Failing after 28s
CI / lint (pull_request) Successful in 6s
CI / typecheck (pull_request) Failing after 31s
CI / test (pull_request) Failing after 43s
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
Fix test failures: email_inbound_token server_default for SQLite (#29)

Strip PostgreSQL-only server_default from email_inbound_token before SQLite create_all(). Add email_inbound_token to test user INSERT statements.

Reviewed-by: Savannah Savings (CTO)
Approved-by: Checkout Charlie (QA)
2026-05-23 23:25:03 +00:00
Savannah Savings ec4eaa1f03 Merge pull request 'Fix ruff lint errors across codebase' (#30) from barcode-betty/car-1004-fix-ruff-lint into dev
CI / build-and-push (push) Has been skipped
CI / deploy-uat (push) Has been skipped
CI / build-and-push (pull_request) Has been skipped
CI / test (push) Failing after 1s
CI / deploy-dev (push) Failing after 1s
CI / typecheck (push) Failing after 0s
CI / typecheck (pull_request) Failing after 17s
CI / lint (push) Failing after 1s
CI / lint (pull_request) Failing after 3s
CI / test (pull_request) Failing after 49s
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
Merge PR #30: Fix ruff lint errors across codebase

Fixes 56 ruff lint errors (E501, F401, I001) in cartsnitch/api.
QA: cs_charlie APPROVED
CTO: cs_savannah APPROVED
2026-05-23 23:11:54 +00:00
Barcode Betty c68838acf2 Fix ruff lint errors across codebase
CI / lint (pull_request) Successful in 5s
CI / typecheck (pull_request) Failing after 29s
CI / test (pull_request) Failing after 48s
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
- Auto-fix F401 (unused imports) and I001 (unsorted imports) with ruff --fix
- Manually fix E501 (line too long) in alembic migrations and src/ models
- Run ruff format to ensure consistent formatting

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 22:47:17 +00:00
19 changed files with 342 additions and 78 deletions
+6 -1
View File
@@ -45,7 +45,11 @@ def run_migrations_online() -> None:
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, version_table_column_width=128)
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table_column_width=128,
)
with context.begin_transaction():
context.run_migrations()
# Create any tables defined in models but not yet created by migrations.
@@ -56,6 +60,7 @@ def run_migrations_online() -> None:
connection.commit()
except Exception as exc:
import logging
logging.getLogger("alembic.env").warning(
"create_all failed (non-fatal, migrations should handle table creation): %s", exc
)
+44 -9
View File
@@ -30,7 +30,10 @@ def upgrade() -> None:
if inspector.has_table("users"):
existing_user_cols = [c["name"] for c in inspector.get_columns("users")]
if "email_verified" not in existing_user_cols:
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
op.add_column(
"users",
sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"),
)
if "image" not in existing_user_cols:
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
@@ -44,8 +47,18 @@ def upgrade() -> None:
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ip_address", sa.Text(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
@@ -66,8 +79,18 @@ def upgrade() -> None:
sa.Column("scope", sa.Text(), nullable=True),
sa.Column("id_token", sa.Text(), nullable=True),
sa.Column("password", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
@@ -80,8 +103,18 @@ def upgrade() -> None:
sa.Column("identifier", sa.Text(), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
@@ -96,8 +129,10 @@ def upgrade() -> None:
user_id_str = str(user_id)
conn.execute(
text(
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
"INSERT INTO accounts "
"(id, user_id, account_id, provider_id, password, created_at, updated_at) "
"VALUES (gen_random_uuid()::text, :user_id, :account_id, "
"'credential', :password, now(), now())"
),
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
)
+12 -2
View File
@@ -40,7 +40,12 @@ def upgrade() -> None:
return # already TEXT — nothing to do
# Step 1: Drop existing FK constraints (ignore if they don't exist)
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
op.execute(
text(
"ALTER TABLE user_store_accounts "
"DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"
)
)
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
# Step 2: Alter users.id from uuid to text
@@ -89,7 +94,12 @@ def upgrade() -> None:
def downgrade() -> None:
# Drop FK constraints
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
op.execute(
text(
"ALTER TABLE user_store_accounts "
"DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"
)
)
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
# Revert users.id from text to uuid
@@ -20,7 +20,7 @@ depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Guard: on a fresh DB Base.metadata.create_all creates users table with the column already present
# Guard: on fresh DB, Base.metadata.create_all already has the column
if not inspector.has_table("users"):
return
existing_cols = [c["name"] for c in inspector.get_columns("users")]
@@ -6,6 +6,7 @@ Create Date: 2026-04-04
"""
import sqlalchemy as sa
from alembic import op
revision = "006_email_inbound_token_server_default"
@@ -29,7 +30,8 @@ def upgrade() -> None:
"users",
"email_inbound_token",
server_default=sa.text(
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
"replace(replace(trim(trailing '=' from "
"encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
),
)
+13 -3
View File
@@ -27,7 +27,8 @@ def upgrade() -> None:
if inspector.has_table("users"):
return # Table already exists (non-fresh DB or create_all already ran)
conn.execute(text("""
conn.execute(
text("""
CREATE TABLE users (
id TEXT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
@@ -36,11 +37,20 @@ def upgrade() -> None:
email_verified BOOLEAN NOT NULL DEFAULT false,
image TEXT,
email_inbound_token VARCHAR(22) NOT NULL UNIQUE
DEFAULT replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_'),
DEFAULT (
replace(
replace(
trim(trailing '=' from encode(gen_random_bytes(16), 'base64')),
'+', '-'
),
'/', '_'
)
),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""))
""")
)
def downgrade() -> None:
+150 -26
View File
@@ -29,8 +29,18 @@ def upgrade() -> None:
sa.Column("slug", sa.String(20), nullable=False, unique=True),
sa.Column("logo_url", sa.String(500), nullable=True),
sa.Column("website_url", sa.String(500), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# 2. store_locations
@@ -45,8 +55,18 @@ def upgrade() -> None:
sa.Column("zip", sa.String(10), nullable=False),
sa.Column("lat", sa.Float(), nullable=True),
sa.Column("lng", sa.Float(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# 3. normalized_products
@@ -61,8 +81,18 @@ def upgrade() -> None:
sa.Column("size", sa.String(50), nullable=True),
sa.Column("size_unit", sa.String(10), nullable=True),
sa.Column("upc_variants", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# 4. purchases
@@ -72,7 +102,9 @@ def upgrade() -> None:
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("user_id", sa.Text(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
sa.Column("store_location_id", sa.Uuid(), sa.ForeignKey("store_locations.id"), nullable=True),
sa.Column(
"store_location_id", sa.Uuid(), sa.ForeignKey("store_locations.id"), nullable=True
),
sa.Column("receipt_id", sa.String(200), nullable=False),
sa.Column("purchase_date", sa.Date(), nullable=False),
sa.Column("total", sa.Numeric(10, 2), nullable=False),
@@ -81,9 +113,24 @@ def upgrade() -> None:
sa.Column("savings_total", sa.Numeric(10, 2), nullable=True),
sa.Column("source_url", sa.String(500), nullable=True),
sa.Column("raw_data", sa.JSON(), nullable=True),
sa.Column("ingested_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"ingested_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint("user_id", "store_id", "receipt_id", name="uq_purchase_receipt"),
sa.Index("ix_purchases_user_store", "user_id", "store_id"),
)
@@ -104,9 +151,24 @@ def upgrade() -> None:
sa.Column("coupon_discount", sa.Numeric(10, 2), nullable=True),
sa.Column("loyalty_discount", sa.Numeric(10, 2), nullable=True),
sa.Column("category_raw", sa.String(100), nullable=True),
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"normalized_product_id",
sa.Uuid(),
sa.ForeignKey("normalized_products.id"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# 6. coupons
@@ -115,7 +177,12 @@ def upgrade() -> None:
"coupons",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=True),
sa.Column(
"normalized_product_id",
sa.Uuid(),
sa.ForeignKey("normalized_products.id"),
nullable=True,
),
sa.Column("title", sa.String(300), nullable=False),
sa.Column("description", sa.String(1000), nullable=True),
sa.Column("discount_type", sa.String(20), nullable=False),
@@ -127,8 +194,18 @@ def upgrade() -> None:
sa.Column("coupon_code", sa.String(100), nullable=True),
sa.Column("source_url", sa.String(500), nullable=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# 7. price_history
@@ -136,7 +213,12 @@ def upgrade() -> None:
op.create_table(
"price_history",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=False),
sa.Column(
"normalized_product_id",
sa.Uuid(),
sa.ForeignKey("normalized_products.id"),
nullable=False,
),
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
sa.Column("observed_date", sa.Date(), nullable=False),
sa.Column("regular_price", sa.Numeric(10, 2), nullable=False),
@@ -144,10 +226,27 @@ def upgrade() -> None:
sa.Column("loyalty_price", sa.Numeric(10, 2), nullable=True),
sa.Column("coupon_price", sa.Numeric(10, 2), nullable=True),
sa.Column("source", sa.String(20), nullable=False),
sa.Column("purchase_item_id", sa.Uuid(), sa.ForeignKey("purchase_items.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Index("ix_price_history_product_store_date", "normalized_product_id", "store_id", "observed_date"),
sa.Column(
"purchase_item_id", sa.Uuid(), sa.ForeignKey("purchase_items.id"), nullable=True
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Index(
"ix_price_history_product_store_date",
"normalized_product_id",
"store_id",
"observed_date",
),
)
# 8. shrinkflation_events
@@ -155,7 +254,12 @@ def upgrade() -> None:
op.create_table(
"shrinkflation_events",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=False),
sa.Column(
"normalized_product_id",
sa.Uuid(),
sa.ForeignKey("normalized_products.id"),
nullable=False,
),
sa.Column("detected_date", sa.Date(), nullable=False),
sa.Column("old_size", sa.String(50), nullable=False),
sa.Column("new_size", sa.String(50), nullable=False),
@@ -165,8 +269,18 @@ def upgrade() -> None:
sa.Column("price_at_new_size", sa.Numeric(10, 2), nullable=True),
sa.Column("confidence", sa.Numeric(3, 2), server_default=text("1.00"), nullable=False),
sa.Column("notes", sa.String(1000), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# 9. user_store_accounts
@@ -180,8 +294,18 @@ def upgrade() -> None:
sa.Column("session_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(20), server_default=text("'active'"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),
)
@@ -6,6 +6,7 @@ Create Date: 2026-04-14
"""
import sqlalchemy as sa
from alembic import op
revision = "009_add_gin_index_upc_variants"
+2 -1
View File
@@ -5,7 +5,8 @@ Sessions are verified by querying the shared sessions table directly.
"""
from datetime import UTC, datetime
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
from fastapi import Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
-3
View File
@@ -6,13 +6,10 @@ endpoints that query our own user data from the shared database.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.models import User
from cartsnitch_api.schemas import (
UpdateUserRequest,
UserResponse,
+15 -8
View File
@@ -6,14 +6,21 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from cartsnitch_api.config import settings
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
)
def _build_engine_kwargs() -> dict:
url = settings.database_url
kwargs: dict = {"echo": False}
if not url.startswith("sqlite"):
kwargs.update(
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
)
return kwargs
engine = create_async_engine(settings.database_url, **_build_engine_kwargs())
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+2 -1
View File
@@ -6,10 +6,10 @@ from fastapi import APIRouter, FastAPI
from cartsnitch_api.auth.routes import router as auth_router
from cartsnitch_api.cache import cache_client
from cartsnitch_api.middleware.audit import add_audit_middleware
from cartsnitch_api.middleware.cors import add_cors_middleware
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
from cartsnitch_api.middleware.audit import add_audit_middleware
from cartsnitch_api.routes.alerts import router as alerts_router
from cartsnitch_api.routes.coupons import router as coupons_router
from cartsnitch_api.routes.health import router as health_router
@@ -26,6 +26,7 @@ from cartsnitch_api.routes.user import router as user_router
@asynccontextmanager
async def lifespan(app: FastAPI):
from cartsnitch_api.database import dispose_engine
await cache_client.initialize()
yield
await cache_client.close()
+18 -5
View File
@@ -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
)
+2 -1
View File
@@ -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,
)
+6 -4
View File
@@ -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,12 +23,12 @@ 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"
Boolean, nullable=False, default=False, server_default="false"
)
image: Mapped[str | None] = mapped_column(Text, nullable=True)
email_inbound_token: Mapped[str] = mapped_column(
@@ -36,7 +37,8 @@ class User(TimestampMixin, Base):
unique=True,
default=lambda: secrets.token_urlsafe(16),
server_default=sa.text(
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
"replace(replace(trim(trailing '=' from "
"encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
),
)
+26 -1
View File
@@ -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)
+22 -5
View File
@@ -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="
@@ -53,17 +56,27 @@ def disable_rate_limiting():
def engine():
"""Sync in-memory SQLite engine for model unit tests.
Strips PostgreSQL-specific server_default expressions so SQLite can
Strips ALL PostgreSQL-specific server_default expressions so SQLite can
handle all column inserts without missing-function errors.
"""
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)
@@ -93,8 +106,12 @@ async def db_engine():
for col in 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
async with engine.begin() as conn:
+12 -3
View File
@@ -28,7 +28,10 @@ def test_database_url_normalizes_plain_postgresql_prefix():
"DATABASE_URL": "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
}
settings = Settings(**env)
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
assert (
settings.database_url
== "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
)
def test_database_url_preserves_asyncpg_prefix():
@@ -37,10 +40,16 @@ def test_database_url_preserves_asyncpg_prefix():
"CARTSNITCH_DATABASE_URL": "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
}
settings = Settings(**env)
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
assert (
settings.database_url
== "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
)
def test_database_url_default():
"""When neither env var is set, the hardcoded default is used."""
settings = Settings()
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
assert (
settings.database_url
== "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
)
+7 -3
View File
@@ -18,12 +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)