fix(api): revert auth/type regressions from standalone sync, keep email-in feature only
- Revert auth/dependencies.py to cookie+Bearer dual auth with str user IDs - Add GET /auth/me/email-in-address endpoint for receipt email routing - Update User model: add email_inbound_token, change id/store_id/user_id to str - Update AuthService and UserResponse to use str user IDs - Update route count test: 33 -> 34 routes - Restore e2e test for email-in-address endpoint Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,164 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: cartsnitch/api
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: runners-cartsnitch
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
- run: pip install ruff
|
||||||
|
- name: Ruff lint
|
||||||
|
run: ruff check .
|
||||||
|
- name: Ruff format check
|
||||||
|
run: ruff format --check .
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: runners-cartsnitch
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y libpq-dev build-essential
|
||||||
|
- name: Install cartsnitch-common from GitHub
|
||||||
|
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git"
|
||||||
|
- run: pip install -e ".[dev]" mypy
|
||||||
|
- name: Type check
|
||||||
|
run: mypy src/cartsnitch_api
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: runners-cartsnitch
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
credentials:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: cartsnitch
|
||||||
|
POSTGRES_PASSWORD: cartsnitch_test
|
||||||
|
POSTGRES_DB: cartsnitch_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
credentials:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
env:
|
||||||
|
CARTSNITCH_DATABASE_URL: postgresql+asyncpg://cartsnitch:cartsnitch_test@localhost:5432/cartsnitch_test
|
||||||
|
CARTSNITCH_REDIS_URL: redis://localhost:6379/0
|
||||||
|
CARTSNITCH_JWT_SECRET_KEY: test-secret-do-not-use-in-prod
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y libpq-dev build-essential
|
||||||
|
- name: Install cartsnitch-common from GitHub
|
||||||
|
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git"
|
||||||
|
- run: pip install -e ".[dev]"
|
||||||
|
- name: Run tests
|
||||||
|
run: pytest --tb=short -q
|
||||||
|
|
||||||
|
build-and-push:
|
||||||
|
runs-on: runners-cartsnitch
|
||||||
|
needs: [lint, test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Generate CalVer tag
|
||||||
|
id: calver
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
DATE_TAG=$(date -u +%Y.%m.%d)
|
||||||
|
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
|
||||||
|
if [ -z "$EXISTING" ]; then
|
||||||
|
VERSION="$DATE_TAG"
|
||||||
|
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then
|
||||||
|
VERSION="${DATE_TAG}.2"
|
||||||
|
else
|
||||||
|
BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//")
|
||||||
|
VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "CalVer tag: $VERSION"
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=sha,prefix=sha-
|
||||||
|
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
target: prod
|
||||||
|
|
||||||
|
- name: Create git tag
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
git tag "v${{ steps.calver.outputs.version }}"
|
||||||
|
git push origin "v${{ steps.calver.outputs.version }}"
|
||||||
+4
-11
@@ -1,5 +1,3 @@
|
|||||||
# Stage 1: Build dependencies
|
|
||||||
# Build context is the repo root. Paths below are relative to the root.
|
|
||||||
FROM python:3.12-slim AS build
|
FROM python:3.12-slim AS build
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -8,21 +6,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY api/pyproject.toml ./
|
COPY pyproject.toml ./
|
||||||
COPY api/src/ ./src/
|
COPY src/ ./src/
|
||||||
RUN pip install --no-cache-dir --prefix=/install .
|
RUN pip install --no-cache-dir --prefix=/install .
|
||||||
|
|
||||||
# Stage 2: Production image
|
|
||||||
FROM python:3.12-slim AS prod
|
FROM python:3.12-slim AS prod
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN adduser --system --group --uid 1000 app
|
RUN adduser --system --group --uid 1000 app
|
||||||
COPY --from=build /install /usr/local
|
COPY --from=build /install /usr/local
|
||||||
COPY api/src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY api/alembic.ini ./
|
|
||||||
COPY api/alembic/ ./alembic/
|
|
||||||
|
|
||||||
USER 1000
|
USER 1000
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
@@ -30,4 +23,4 @@ EXPOSE 8000
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s \
|
HEALTHCHECK --interval=30s --timeout=3s \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||||
|
|
||||||
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"]
|
CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -5,8 +5,6 @@ Sessions are verified by querying the shared sessions table directly.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
|
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -23,10 +21,10 @@ bearer_scheme = HTTPBearer(auto_error=False)
|
|||||||
SESSION_COOKIE_NAME = "better-auth.session_token"
|
SESSION_COOKIE_NAME = "better-auth.session_token"
|
||||||
|
|
||||||
|
|
||||||
async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
|
async def _validate_session_token(token: str, db: AsyncSession) -> str:
|
||||||
"""Validate a Better-Auth session token against the sessions table.
|
"""Validate a Better-Auth session token against the sessions table.
|
||||||
|
|
||||||
Returns the user_id (as UUID) if the session is valid and not expired.
|
Returns the user_id (as str) if the session is valid and not expired.
|
||||||
"""
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
|
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
|
||||||
@@ -41,9 +39,6 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
|
|||||||
)
|
)
|
||||||
|
|
||||||
user_id, expires_at = row
|
user_id, expires_at = row
|
||||||
# SQLite stores datetimes as ISO strings; parse if necessary
|
|
||||||
if isinstance(expires_at, str):
|
|
||||||
expires_at = datetime.fromisoformat(expires_at)
|
|
||||||
if expires_at.tzinfo is None:
|
if expires_at.tzinfo is None:
|
||||||
# Treat naive datetimes as UTC
|
# Treat naive datetimes as UTC
|
||||||
expires_at = expires_at.replace(tzinfo=UTC)
|
expires_at = expires_at.replace(tzinfo=UTC)
|
||||||
@@ -54,14 +49,14 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
|
|||||||
detail="Session expired",
|
detail="Session expired",
|
||||||
)
|
)
|
||||||
|
|
||||||
return UUID(str(user_id))
|
return str(user_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> UUID:
|
) -> str:
|
||||||
"""Extract and validate the session token from cookie or Authorization header.
|
"""Extract and validate the session token from cookie or Authorization header.
|
||||||
|
|
||||||
Checks in order:
|
Checks in order:
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ the Better-Auth service (auth/). This router provides user profile
|
|||||||
endpoints that query our own user data from the shared database.
|
endpoints that query our own user data from the shared database.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from cartsnitch_api.auth.dependencies import get_current_user
|
from cartsnitch_api.auth.dependencies import get_current_user
|
||||||
from cartsnitch_api.database import get_db
|
from cartsnitch_api.database import get_db
|
||||||
|
from cartsnitch_api.models import User
|
||||||
from cartsnitch_api.schemas import (
|
from cartsnitch_api.schemas import (
|
||||||
EmailInAddressResponse,
|
|
||||||
UpdateUserRequest,
|
UpdateUserRequest,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
@@ -22,9 +22,14 @@ from cartsnitch_api.services.auth import AuthService
|
|||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class EmailInAddressResponse(BaseModel):
|
||||||
|
email_address: str
|
||||||
|
instructions: str
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def get_me(
|
async def get_me(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = AuthService(db)
|
svc = AuthService(db)
|
||||||
@@ -39,7 +44,7 @@ async def get_me(
|
|||||||
@router.patch("/me", response_model=UserResponse)
|
@router.patch("/me", response_model=UserResponse)
|
||||||
async def update_me(
|
async def update_me(
|
||||||
body: UpdateUserRequest,
|
body: UpdateUserRequest,
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = AuthService(db)
|
svc = AuthService(db)
|
||||||
@@ -55,7 +60,7 @@ async def update_me(
|
|||||||
|
|
||||||
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_me(
|
async def delete_me(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = AuthService(db)
|
svc = AuthService(db)
|
||||||
@@ -69,13 +74,19 @@ async def delete_me(
|
|||||||
|
|
||||||
@router.get("/me/email-in-address", response_model=EmailInAddressResponse)
|
@router.get("/me/email-in-address", response_model=EmailInAddressResponse)
|
||||||
async def get_email_in_address(
|
async def get_email_in_address(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = AuthService(db)
|
result = await db.execute(select(User.email_inbound_token).where(User.id == user_id))
|
||||||
try:
|
token = result.scalar_one_or_none()
|
||||||
return await svc.get_email_in_address(user_id)
|
if not token:
|
||||||
except LookupError:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Email inbound token not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Email inbound token not found"
|
||||||
) from None
|
) from None
|
||||||
|
return EmailInAddressResponse(
|
||||||
|
email_address=f"receipts+{token}@receipts.cartsnitch.com",
|
||||||
|
instructions=(
|
||||||
|
"Forward your digital receipt emails to this address. "
|
||||||
|
"We currently support Meijer, Kroger, and Target receipt emails."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class Settings(BaseSettings):
|
|||||||
# Valid Fernet key for local dev — MUST be overridden in production
|
# Valid Fernet key for local dev — MUST be overridden in production
|
||||||
fernet_key: str = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
|
fernet_key: str = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
|
||||||
|
|
||||||
|
auth_service_url: str = "http://auth:3001"
|
||||||
|
|
||||||
cors_origins: list[str] = ["http://localhost:3000", "https://cartsnitch.com"]
|
cors_origins: list[str] = ["http://localhost:3000", "https://cartsnitch.com"]
|
||||||
|
|
||||||
receiptwitness_url: str = "http://receiptwitness:8001"
|
receiptwitness_url: str = "http://receiptwitness:8001"
|
||||||
|
|||||||
@@ -1,39 +1,12 @@
|
|||||||
"""Base model and mixins for all CartSnitch ORM models."""
|
"""Base model and mixins for all CartSnitch ORM models."""
|
||||||
|
|
||||||
import uuid as uuid_lib
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, String, TypeDecorator, func
|
from sqlalchemy import DateTime, func
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
class UUIDString(TypeDecorator):
|
|
||||||
"""Store UUIDs as VARCHAR(36) strings in all dialects.
|
|
||||||
|
|
||||||
This handles the fundamental mismatch between Python's uuid.UUID objects
|
|
||||||
(used everywhere in application code) and SQLite's lack of a native UUID type.
|
|
||||||
- On INSERT: converts uuid.UUID → str
|
|
||||||
- On SELECT: returns uuid.UUID (so SQLAlchemy 2.0 sentinel tracking matches correctly)
|
|
||||||
"""
|
|
||||||
|
|
||||||
impl = String(36)
|
|
||||||
cache_ok = True
|
|
||||||
|
|
||||||
def process_bind_param(self, value, dialect):
|
|
||||||
if value is None:
|
|
||||||
return value
|
|
||||||
if isinstance(value, uuid_lib.UUID):
|
|
||||||
return str(value)
|
|
||||||
return value # already a string
|
|
||||||
|
|
||||||
def process_result_value(self, value, dialect):
|
|
||||||
if value is None:
|
|
||||||
return value
|
|
||||||
if isinstance(value, uuid_lib.UUID):
|
|
||||||
return value
|
|
||||||
return uuid_lib.UUID(value) # convert str → UUID for correct sentinel tracking
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
"""Base class for all CartSnitch models."""
|
"""Base class for all CartSnitch models."""
|
||||||
|
|
||||||
@@ -50,14 +23,8 @@ class TimestampMixin:
|
|||||||
|
|
||||||
|
|
||||||
class UUIDPrimaryKeyMixin:
|
class UUIDPrimaryKeyMixin:
|
||||||
"""Mixin providing a UUID primary key.
|
"""Mixin providing a UUID primary key."""
|
||||||
|
|
||||||
Uses UUIDString so all DB dialects store the full 36-char UUID string
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
without truncation, while Python code always works with uuid.UUID objects.
|
primary_key=True, default=uuid.uuid4, server_default=func.gen_random_uuid()
|
||||||
"""
|
|
||||||
|
|
||||||
id: Mapped[uuid_lib.UUID] = mapped_column(
|
|
||||||
UUIDString(),
|
|
||||||
primary_key=True,
|
|
||||||
default=uuid_lib.uuid4,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
|
|
||||||
__tablename__ = "purchases"
|
__tablename__ = "purchases"
|
||||||
|
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||||
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
|
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
|
||||||
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
|
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
purchase_date: Mapped[date] = mapped_column(Date, nullable=False)
|
purchase_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"""User and UserStoreAccount models."""
|
"""User and UserStoreAccount models."""
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from cartsnitch_api.constants import AccountStatus
|
from cartsnitch_api.constants import AccountStatus
|
||||||
@@ -17,11 +16,12 @@ if TYPE_CHECKING:
|
|||||||
from cartsnitch_api.models.store import Store
|
from cartsnitch_api.models.store import Store
|
||||||
|
|
||||||
|
|
||||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
class User(TimestampMixin, Base):
|
||||||
"""Application user."""
|
"""Application user."""
|
||||||
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
display_name: Mapped[str | None] = mapped_column(String(100))
|
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||||
@@ -43,8 +43,8 @@ class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
__tablename__ = "user_store_accounts"
|
__tablename__ = "user_store_accounts"
|
||||||
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
|
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
|
||||||
|
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||||
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
|
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
|
||||||
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Pydantic v2 request/response schemas for all API endpoints."""
|
"""Pydantic v2 request/response schemas for all API endpoints."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
@@ -16,7 +15,7 @@ class UpdateUserRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
id: UUID
|
id: str
|
||||||
email: str
|
email: str
|
||||||
display_name: str
|
display_name: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -265,13 +264,6 @@ class ErrorResponse(BaseModel):
|
|||||||
code: str | None = None
|
code: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ---------- Email-In ----------
|
|
||||||
|
|
||||||
class EmailInAddressResponse(BaseModel):
|
|
||||||
email_address: str
|
|
||||||
instructions: str
|
|
||||||
|
|
||||||
|
|
||||||
# Rebuild forward refs
|
# Rebuild forward refs
|
||||||
ProductDetailResponse.model_rebuild()
|
ProductDetailResponse.model_rebuild()
|
||||||
PriceTrendResponse.model_rebuild()
|
PriceTrendResponse.model_rebuild()
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ handled by the Better-Auth service (auth/). This service provides
|
|||||||
user lookup and profile update operations for the API gateway.
|
user lookup and profile update operations for the API gateway.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -15,14 +13,10 @@ class AuthService:
|
|||||||
def __init__(self, db: AsyncSession) -> None:
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
async def get_user(self, user_id: UUID) -> dict:
|
async def get_user(self, user_id: str) -> dict:
|
||||||
from cartsnitch_api.models import User
|
from cartsnitch_api.models import User
|
||||||
|
|
||||||
# Use str() to ensure consistent string comparison for UUID columns
|
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||||
# (works with both SQLite VARCHAR and Postgres UUID storage)
|
|
||||||
result = await self.db.execute(
|
|
||||||
select(User).where(User.id == str(user_id))
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
raise LookupError("User not found")
|
raise LookupError("User not found")
|
||||||
@@ -34,11 +28,10 @@ class AuthService:
|
|||||||
"created_at": user.created_at,
|
"created_at": user.created_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def update_user(self, user_id: UUID, **fields) -> dict:
|
async def update_user(self, user_id: str, **fields) -> dict:
|
||||||
from cartsnitch_api.models import User
|
from cartsnitch_api.models import User
|
||||||
|
|
||||||
user_id_str = str(user_id)
|
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||||
result = await self.db.execute(select(User).where(User.id == user_id_str))
|
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
raise LookupError("User not found")
|
raise LookupError("User not found")
|
||||||
@@ -47,7 +40,7 @@ class AuthService:
|
|||||||
user.display_name = fields["display_name"]
|
user.display_name = fields["display_name"]
|
||||||
if "email" in fields and fields["email"] is not None:
|
if "email" in fields and fields["email"] is not None:
|
||||||
existing = await self.db.execute(
|
existing = await self.db.execute(
|
||||||
select(User).where(User.email == fields["email"], User.id != user_id_str)
|
select(User).where(User.email == fields["email"], User.id != user_id)
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise ValueError("Email already in use")
|
raise ValueError("Email already in use")
|
||||||
@@ -63,31 +56,13 @@ class AuthService:
|
|||||||
"created_at": user.created_at,
|
"created_at": user.created_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def delete_user(self, user_id: UUID) -> None:
|
async def delete_user(self, user_id: str) -> None:
|
||||||
from cartsnitch_api.models import User
|
from cartsnitch_api.models import User
|
||||||
|
|
||||||
result = await self.db.execute(select(User).where(User.id == str(user_id)))
|
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
raise LookupError("User not found")
|
raise LookupError("User not found")
|
||||||
|
|
||||||
await self.db.delete(user)
|
await self.db.delete(user)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|
||||||
async def get_email_in_address(self, user_id: UUID) -> dict:
|
|
||||||
from cartsnitch_api.models import User
|
|
||||||
|
|
||||||
result = await self.db.execute(
|
|
||||||
select(User.email_inbound_token).where(User.id == str(user_id))
|
|
||||||
)
|
|
||||||
token = result.scalar_one_or_none()
|
|
||||||
if not token:
|
|
||||||
raise LookupError("Email inbound token not found")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"email_address": f"receipts+{token}@receipts.cartsnitch.com",
|
|
||||||
"instructions": (
|
|
||||||
"Forward your digital receipt emails to this address. "
|
|
||||||
"We currently support Meijer, Kroger, and Target receipt emails."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-4
@@ -141,7 +141,6 @@ async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_o
|
|||||||
user_id = str(uuid.uuid4())
|
user_id = str(uuid.uuid4())
|
||||||
email = user_overrides.get("email", "test@example.com")
|
email = user_overrides.get("email", "test@example.com")
|
||||||
display_name = user_overrides.get("display_name", "Test User")
|
display_name = user_overrides.get("display_name", "Test User")
|
||||||
email_inbound_token = user_overrides.get("email_inbound_token", secrets.token_urlsafe(16))
|
|
||||||
session_token = secrets.token_urlsafe(32)
|
session_token = secrets.token_urlsafe(32)
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
@@ -150,15 +149,15 @@ async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_o
|
|||||||
async with db_engine.begin() as conn:
|
async with db_engine.begin() as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO users (id, email, hashed_password, display_name, email_inbound_token, created_at, updated_at) "
|
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
|
||||||
"VALUES (:id, :email, :hashed_password, :display_name, :email_inbound_token, :created_at, :updated_at)"
|
"VALUES (:id, :email, :hashed_password, :display_name, :email_verified, :created_at, :updated_at)"
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
"email": email,
|
"email": email,
|
||||||
"hashed_password": "not-used-with-better-auth",
|
"hashed_password": "not-used-with-better-auth",
|
||||||
"display_name": display_name,
|
"display_name": display_name,
|
||||||
"email_inbound_token": email_inbound_token,
|
"email_verified": False,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,15 +88,15 @@ async def test_expired_session_rejected(client, db_engine):
|
|||||||
async with db_engine.begin() as conn:
|
async with db_engine.begin() as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO users (id, email, hashed_password, display_name, email_inbound_token, created_at, updated_at) "
|
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
|
||||||
"VALUES (:id, :email, :hp, :dn, :eit, :ca, :ua)"
|
"VALUES (:id, :email, :hp, :dn, :ev, :ca, :ua)"
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
"email": "expired@example.com",
|
"email": "expired@example.com",
|
||||||
"hp": "unused",
|
"hp": "unused",
|
||||||
"dn": "Expired User",
|
"dn": "Expired User",
|
||||||
"eit": secrets.token_urlsafe(16),
|
"ev": False,
|
||||||
"ca": now,
|
"ca": now,
|
||||||
"ua": now,
|
"ua": now,
|
||||||
},
|
},
|
||||||
|
|||||||
+17
-45
@@ -7,11 +7,10 @@ exercise cross-resource queries against real data.
|
|||||||
|
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
import uuid
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from cartsnitch_api.models import (
|
from cartsnitch_api.models import (
|
||||||
@@ -27,27 +26,24 @@ from cartsnitch_api.models import (
|
|||||||
# Shared test constants
|
# Shared test constants
|
||||||
ZERO_UUID = "00000000-0000-0000-0000-000000000000"
|
ZERO_UUID = "00000000-0000-0000-0000-000000000000"
|
||||||
BAD_UUID = "not-a-uuid"
|
BAD_UUID = "not-a-uuid"
|
||||||
# Anchor date relative to today so coupon validity windows stay in the future
|
# Fixed anchor date for deterministic tests
|
||||||
ANCHOR_DATE = date.today()
|
ANCHOR_DATE = date(2026, 3, 15)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def seed_data(db_engine, auth_headers):
|
async def seed_data(db_engine, auth_headers):
|
||||||
"""Seed a full dataset and return identifiers for test assertions."""
|
"""Seed a full dataset and return identifiers for test assertions."""
|
||||||
import uuid
|
|
||||||
|
|
||||||
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
async with factory() as session:
|
async with factory() as session:
|
||||||
# -- Stores --
|
# -- Stores --
|
||||||
meijer = Store(name="Meijer", slug="meijer", id=uuid.uuid4())
|
meijer = Store(name="Meijer", slug="meijer")
|
||||||
kroger = Store(name="Kroger", slug="kroger", id=uuid.uuid4())
|
kroger = Store(name="Kroger", slug="kroger")
|
||||||
target = Store(name="Target", slug="target", id=uuid.uuid4())
|
target = Store(name="Target", slug="target")
|
||||||
session.add_all([meijer, kroger, target])
|
session.add_all([meijer, kroger, target])
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
# -- Products --
|
# -- Products --
|
||||||
cheerios = NormalizedProduct(
|
cheerios = NormalizedProduct(
|
||||||
id=uuid.uuid4(),
|
|
||||||
canonical_name="Cheerios 18oz",
|
canonical_name="Cheerios 18oz",
|
||||||
category="pantry",
|
category="pantry",
|
||||||
brand="General Mills",
|
brand="General Mills",
|
||||||
@@ -56,7 +52,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
upc_variants=["016000275263"],
|
upc_variants=["016000275263"],
|
||||||
)
|
)
|
||||||
milk = NormalizedProduct(
|
milk = NormalizedProduct(
|
||||||
id=uuid.uuid4(),
|
|
||||||
canonical_name="Whole Milk 1gal",
|
canonical_name="Whole Milk 1gal",
|
||||||
category="dairy",
|
category="dairy",
|
||||||
brand="Meijer",
|
brand="Meijer",
|
||||||
@@ -64,7 +59,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
size_unit="gal",
|
size_unit="gal",
|
||||||
)
|
)
|
||||||
chicken = NormalizedProduct(
|
chicken = NormalizedProduct(
|
||||||
id=uuid.uuid4(),
|
|
||||||
canonical_name="Chicken Breast 1lb",
|
canonical_name="Chicken Breast 1lb",
|
||||||
category="meat",
|
category="meat",
|
||||||
brand=None,
|
brand=None,
|
||||||
@@ -81,7 +75,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
for i, price_val in enumerate([Decimal("3.99"), Decimal("4.29"), Decimal("4.79")]):
|
for i, price_val in enumerate([Decimal("3.99"), Decimal("4.29"), Decimal("4.79")]):
|
||||||
prices.append(
|
prices.append(
|
||||||
PriceHistory(
|
PriceHistory(
|
||||||
id=uuid.uuid4(),
|
|
||||||
normalized_product_id=cheerios.id,
|
normalized_product_id=cheerios.id,
|
||||||
store_id=meijer.id,
|
store_id=meijer.id,
|
||||||
observed_date=today - timedelta(days=60 - i * 30),
|
observed_date=today - timedelta(days=60 - i * 30),
|
||||||
@@ -93,7 +86,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
for i in range(3):
|
for i in range(3):
|
||||||
prices.append(
|
prices.append(
|
||||||
PriceHistory(
|
PriceHistory(
|
||||||
id=uuid.uuid4(),
|
|
||||||
normalized_product_id=cheerios.id,
|
normalized_product_id=cheerios.id,
|
||||||
store_id=kroger.id,
|
store_id=kroger.id,
|
||||||
observed_date=today - timedelta(days=60 - i * 30),
|
observed_date=today - timedelta(days=60 - i * 30),
|
||||||
@@ -104,7 +96,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
# Milk at Meijer
|
# Milk at Meijer
|
||||||
prices.append(
|
prices.append(
|
||||||
PriceHistory(
|
PriceHistory(
|
||||||
id=uuid.uuid4(),
|
|
||||||
normalized_product_id=milk.id,
|
normalized_product_id=milk.id,
|
||||||
store_id=meijer.id,
|
store_id=meijer.id,
|
||||||
observed_date=today - timedelta(days=7),
|
observed_date=today - timedelta(days=7),
|
||||||
@@ -115,7 +106,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
# Milk at Kroger
|
# Milk at Kroger
|
||||||
prices.append(
|
prices.append(
|
||||||
PriceHistory(
|
PriceHistory(
|
||||||
id=uuid.uuid4(),
|
|
||||||
normalized_product_id=milk.id,
|
normalized_product_id=milk.id,
|
||||||
store_id=kroger.id,
|
store_id=kroger.id,
|
||||||
observed_date=today - timedelta(days=5),
|
observed_date=today - timedelta(days=5),
|
||||||
@@ -126,7 +116,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
# Chicken at Target
|
# Chicken at Target
|
||||||
prices.append(
|
prices.append(
|
||||||
PriceHistory(
|
PriceHistory(
|
||||||
id=uuid.uuid4(),
|
|
||||||
normalized_product_id=chicken.id,
|
normalized_product_id=chicken.id,
|
||||||
store_id=target.id,
|
store_id=target.id,
|
||||||
observed_date=today - timedelta(days=3),
|
observed_date=today - timedelta(days=3),
|
||||||
@@ -137,29 +126,19 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
session.add_all(prices)
|
session.add_all(prices)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
# -- Purchases (need the user_id from the registered test user) --
|
# -- Get the user_id from the session token in auth_headers --
|
||||||
# Extract session_token from auth_headers, then look up the real user_id
|
cookie_str = auth_headers.get("Cookie", "")
|
||||||
import http.cookies
|
session_token = cookie_str.split("=", 1)[1] if "=" in cookie_str else ""
|
||||||
cookie_header = auth_headers.get("Cookie", "")
|
|
||||||
cookies = http.cookies.SimpleCookie()
|
|
||||||
cookies.load(cookie_header)
|
|
||||||
session_token = cookies.get("better-auth.session_token").value if "better-auth.session_token" in cookie_header else None
|
|
||||||
if session_token is None:
|
|
||||||
raise RuntimeError("seed_data fixture requires cookie-based auth session token")
|
|
||||||
|
|
||||||
# Look up the real user_id from the sessions table
|
result = await session.execute(
|
||||||
row = await session.execute(
|
|
||||||
text("SELECT user_id FROM sessions WHERE token = :token"),
|
text("SELECT user_id FROM sessions WHERE token = :token"),
|
||||||
{"token": session_token}
|
{"token": session_token},
|
||||||
)
|
)
|
||||||
session_row = row.fetchone()
|
row = result.first()
|
||||||
if session_row is None:
|
user_id = UUID(row[0])
|
||||||
raise RuntimeError("Session not found for session token in auth_headers")
|
|
||||||
real_user_id = session_row[0]
|
|
||||||
|
|
||||||
purchase1 = Purchase(
|
purchase1 = Purchase(
|
||||||
id=uuid.uuid4(),
|
user_id=user_id,
|
||||||
user_id=uuid.UUID(real_user_id),
|
|
||||||
store_id=meijer.id,
|
store_id=meijer.id,
|
||||||
receipt_id="meijer-2026-001",
|
receipt_id="meijer-2026-001",
|
||||||
purchase_date=today - timedelta(days=10),
|
purchase_date=today - timedelta(days=10),
|
||||||
@@ -168,8 +147,7 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
tax=Decimal("1.95"),
|
tax=Decimal("1.95"),
|
||||||
)
|
)
|
||||||
purchase2 = Purchase(
|
purchase2 = Purchase(
|
||||||
id=uuid.uuid4(),
|
user_id=user_id,
|
||||||
user_id=uuid.UUID(real_user_id),
|
|
||||||
store_id=kroger.id,
|
store_id=kroger.id,
|
||||||
receipt_id="kroger-2026-001",
|
receipt_id="kroger-2026-001",
|
||||||
purchase_date=today - timedelta(days=5),
|
purchase_date=today - timedelta(days=5),
|
||||||
@@ -182,7 +160,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
|
|
||||||
# -- Purchase Items --
|
# -- Purchase Items --
|
||||||
item1 = PurchaseItem(
|
item1 = PurchaseItem(
|
||||||
id=uuid.uuid4(),
|
|
||||||
purchase_id=purchase1.id,
|
purchase_id=purchase1.id,
|
||||||
product_name_raw="Cheerios 18oz Box",
|
product_name_raw="Cheerios 18oz Box",
|
||||||
quantity=Decimal("1"),
|
quantity=Decimal("1"),
|
||||||
@@ -191,7 +168,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
normalized_product_id=cheerios.id,
|
normalized_product_id=cheerios.id,
|
||||||
)
|
)
|
||||||
item2 = PurchaseItem(
|
item2 = PurchaseItem(
|
||||||
id=uuid.uuid4(),
|
|
||||||
purchase_id=purchase1.id,
|
purchase_id=purchase1.id,
|
||||||
product_name_raw="Meijer Whole Milk 1gal",
|
product_name_raw="Meijer Whole Milk 1gal",
|
||||||
quantity=Decimal("2"),
|
quantity=Decimal("2"),
|
||||||
@@ -200,7 +176,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
normalized_product_id=milk.id,
|
normalized_product_id=milk.id,
|
||||||
)
|
)
|
||||||
item3 = PurchaseItem(
|
item3 = PurchaseItem(
|
||||||
id=uuid.uuid4(),
|
|
||||||
purchase_id=purchase2.id,
|
purchase_id=purchase2.id,
|
||||||
product_name_raw="KRO CHEERIOS 18OZ",
|
product_name_raw="KRO CHEERIOS 18OZ",
|
||||||
quantity=Decimal("1"),
|
quantity=Decimal("1"),
|
||||||
@@ -213,7 +188,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
|
|
||||||
# -- Coupons --
|
# -- Coupons --
|
||||||
coupon1 = Coupon(
|
coupon1 = Coupon(
|
||||||
id=uuid.uuid4(),
|
|
||||||
store_id=meijer.id,
|
store_id=meijer.id,
|
||||||
normalized_product_id=cheerios.id,
|
normalized_product_id=cheerios.id,
|
||||||
title="$1 off Cheerios",
|
title="$1 off Cheerios",
|
||||||
@@ -224,7 +198,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
valid_to=today + timedelta(days=30),
|
valid_to=today + timedelta(days=30),
|
||||||
)
|
)
|
||||||
coupon2 = Coupon(
|
coupon2 = Coupon(
|
||||||
id=uuid.uuid4(),
|
|
||||||
store_id=kroger.id,
|
store_id=kroger.id,
|
||||||
normalized_product_id=None,
|
normalized_product_id=None,
|
||||||
title="10% off dairy",
|
title="10% off dairy",
|
||||||
@@ -239,7 +212,6 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
|
|
||||||
# -- Shrinkflation events --
|
# -- Shrinkflation events --
|
||||||
shrink = ShrinkflationEvent(
|
shrink = ShrinkflationEvent(
|
||||||
id=uuid.uuid4(),
|
|
||||||
normalized_product_id=cheerios.id,
|
normalized_product_id=cheerios.id,
|
||||||
detected_date=today - timedelta(days=15),
|
detected_date=today - timedelta(days=15),
|
||||||
old_size="20",
|
old_size="20",
|
||||||
@@ -274,7 +246,7 @@ async def seed_data(db_engine, auth_headers):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"headers": auth_headers,
|
"headers": auth_headers,
|
||||||
"user_id": real_user_id,
|
"user_id": user_id,
|
||||||
"stores": {"meijer": meijer, "kroger": kroger, "target": target},
|
"stores": {"meijer": meijer, "kroger": kroger, "target": target},
|
||||||
"products": {"cheerios": cheerios, "milk": milk, "chicken": chicken},
|
"products": {"cheerios": cheerios, "milk": milk, "chicken": chicken},
|
||||||
"purchases": {"meijer_trip": purchase1, "kroger_trip": purchase2},
|
"purchases": {"meijer_trip": purchase1, "kroger_trip": purchase2},
|
||||||
|
|||||||
@@ -65,15 +65,15 @@ class TestSessionValidation:
|
|||||||
async with db_engine.begin() as conn:
|
async with db_engine.begin() as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO users (id, email, hashed_password, display_name, email_inbound_token, created_at, updated_at) "
|
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
|
||||||
"VALUES (:id, :email, :hp, :dn, :eit, :ca, :ua)"
|
"VALUES (:id, :email, :hp, :dn, :ev, :ca, :ua)"
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
"email": "expired@e2e.com",
|
"email": "expired@e2e.com",
|
||||||
"hp": "unused",
|
"hp": "unused",
|
||||||
"dn": "Expired User",
|
"dn": "Expired User",
|
||||||
"eit": secrets.token_urlsafe(16),
|
"ev": False,
|
||||||
"ca": now,
|
"ca": now,
|
||||||
"ua": now,
|
"ua": now,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,74 @@ import pytest
|
|||||||
from tests.test_e2e.conftest import BAD_UUID, ZERO_UUID
|
from tests.test_e2e.conftest import BAD_UUID, ZERO_UUID
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestRegistrationErrors:
|
||||||
|
"""Validation errors during user registration."""
|
||||||
|
|
||||||
|
async def test_short_password(self, client, db_engine):
|
||||||
|
resp = await client.post(
|
||||||
|
"/auth/register",
|
||||||
|
json={"email": "short@example.com", "password": "short", "display_name": "Test"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_invalid_email(self, client, db_engine):
|
||||||
|
resp = await client.post(
|
||||||
|
"/auth/register",
|
||||||
|
json={"email": "not-an-email", "password": "securepass123", "display_name": "Test"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_missing_fields(self, client, db_engine):
|
||||||
|
resp = await client.post("/auth/register", json={})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_empty_display_name(self, client, db_engine):
|
||||||
|
resp = await client.post(
|
||||||
|
"/auth/register",
|
||||||
|
json={"email": "empty@example.com", "password": "securepass123", "display_name": ""},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_duplicate_email(self, client, db_engine):
|
||||||
|
payload = {
|
||||||
|
"email": "dupe@example.com",
|
||||||
|
"password": "securepass123",
|
||||||
|
"display_name": "First",
|
||||||
|
}
|
||||||
|
first = await client.post("/auth/register", json=payload)
|
||||||
|
assert first.status_code == 201
|
||||||
|
second = await client.post("/auth/register", json=payload)
|
||||||
|
assert second.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestLoginErrors:
|
||||||
|
"""Login failure modes."""
|
||||||
|
|
||||||
|
async def test_wrong_password(self, client, db_engine):
|
||||||
|
await client.post(
|
||||||
|
"/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "login-err@example.com",
|
||||||
|
"password": "correctpass1",
|
||||||
|
"display_name": "Login",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"email": "login-err@example.com", "password": "wrongpass123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
async def test_nonexistent_user(self, client, db_engine):
|
||||||
|
resp = await client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"email": "nobody@example.com", "password": "doesntmatter"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
class TestNotFoundErrors:
|
class TestNotFoundErrors:
|
||||||
"""404 responses for missing resources."""
|
"""404 responses for missing resources."""
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ async def test_404_returns_structured_error(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_validation_error_returns_422_with_field_errors(client, auth_headers):
|
async def test_validation_error_returns_422_with_field_errors(client):
|
||||||
"""Invalid request body should return structured validation errors."""
|
"""Invalid request body should return structured validation errors."""
|
||||||
resp = await client.patch(
|
resp = await client.post(
|
||||||
"/auth/me",
|
"/auth/register",
|
||||||
headers=auth_headers,
|
json={"email": "not-an-email", "password": "short", "display_name": ""},
|
||||||
json={"display_name": ""},
|
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
from cartsnitch_api.main import app
|
from cartsnitch_api.main import app
|
||||||
|
|
||||||
EXPECTED_ROUTES = [
|
EXPECTED_ROUTES = [
|
||||||
# Auth (4 — register/login/refresh handled by Better-Auth service)
|
# Auth (7)
|
||||||
|
("post", "/auth/register"),
|
||||||
|
("post", "/auth/login"),
|
||||||
|
("post", "/auth/refresh"),
|
||||||
("get", "/auth/me"),
|
("get", "/auth/me"),
|
||||||
("patch", "/auth/me"),
|
("patch", "/auth/me"),
|
||||||
("delete", "/auth/me"),
|
("delete", "/auth/me"),
|
||||||
@@ -87,4 +90,4 @@ async def test_route_count():
|
|||||||
if method in ("get", "post", "put", "delete", "patch"):
|
if method in ("get", "post", "put", "delete", "patch"):
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
assert count == 31, f"Expected 31 routes, found {count}"
|
assert count == 34, f"Expected 34 routes, found {count}"
|
||||||
|
|||||||
@@ -2,81 +2,44 @@
|
|||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, date, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from cartsnitch_api.models import Purchase, PurchaseItem, Store
|
from cartsnitch_api.models import Purchase, PurchaseItem, Store, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def purchase_data(db_engine):
|
async def purchase_data(db_engine):
|
||||||
"""Seed a user, store, purchase, and items using session-cookie auth."""
|
"""Seed a user, store, purchase, items, and a valid session."""
|
||||||
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
async with factory() as session:
|
async with factory() as session:
|
||||||
user_id = str(uuid.uuid4())
|
user = User(
|
||||||
session_token = secrets.token_urlsafe(32)
|
email="buyer@example.com",
|
||||||
now = datetime.now(UTC).isoformat()
|
hashed_password="not-used-with-better-auth",
|
||||||
expires = (datetime.now(UTC) + timedelta(days=7)).isoformat()
|
display_name="Buyer",
|
||||||
|
|
||||||
# Create the user
|
|
||||||
await session.execute(
|
|
||||||
text(
|
|
||||||
"INSERT INTO users (id, email, hashed_password, display_name, email_inbound_token, created_at, updated_at) "
|
|
||||||
"VALUES (:id, :email, :hashed_password, :display_name, :email_inbound_token, :created_at, :updated_at)"
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"id": user_id,
|
|
||||||
"email": "buyer@example.com",
|
|
||||||
"hashed_password": "not-used-with-better-auth",
|
|
||||||
"display_name": "Buyer",
|
|
||||||
"email_inbound_token": secrets.token_urlsafe(16),
|
|
||||||
"created_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
store = Store(name="Kroger", slug="kroger")
|
||||||
# Create the session
|
session.add_all([user, store])
|
||||||
await session.execute(
|
await session.commit()
|
||||||
text(
|
await session.refresh(user)
|
||||||
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
|
|
||||||
"VALUES (:id, :token, :user_id, :expires_at, :created_at, :updated_at)"
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"token": session_token,
|
|
||||||
"user_id": user_id,
|
|
||||||
"expires_at": expires,
|
|
||||||
"created_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create the store
|
|
||||||
store = Store(name="Kroger", slug="kroger", id=uuid.uuid4())
|
|
||||||
session.add(store)
|
|
||||||
await session.flush()
|
|
||||||
await session.refresh(store)
|
await session.refresh(store)
|
||||||
|
|
||||||
# Create the purchase
|
|
||||||
purchase = Purchase(
|
purchase = Purchase(
|
||||||
id=uuid.uuid4(),
|
user_id=user.id,
|
||||||
user_id=uuid.UUID(user_id),
|
|
||||||
store_id=store.id,
|
store_id=store.id,
|
||||||
receipt_id="receipt-001",
|
receipt_id="receipt-001",
|
||||||
purchase_date=date(2026, 3, 10),
|
purchase_date=date(2026, 3, 10),
|
||||||
total=Decimal("42.50"),
|
total=Decimal("42.50"),
|
||||||
)
|
)
|
||||||
session.add(purchase)
|
session.add(purchase)
|
||||||
await session.flush()
|
await session.commit()
|
||||||
await session.refresh(purchase)
|
await session.refresh(purchase)
|
||||||
|
|
||||||
# Create the purchase item
|
|
||||||
item = PurchaseItem(
|
item = PurchaseItem(
|
||||||
id=uuid.uuid4(),
|
|
||||||
purchase_id=purchase.id,
|
purchase_id=purchase.id,
|
||||||
product_name_raw="Organic Milk 1gal",
|
product_name_raw="Organic Milk 1gal",
|
||||||
quantity=Decimal("1"),
|
quantity=Decimal("1"),
|
||||||
@@ -86,12 +49,33 @@ async def purchase_data(db_engine):
|
|||||||
session.add(item)
|
session.add(item)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return {
|
# Create a session token directly in the sessions table
|
||||||
"user_id": user_id,
|
session_token = secrets.token_urlsafe(32)
|
||||||
"store": store,
|
now = datetime.now(UTC).isoformat()
|
||||||
"purchase": purchase,
|
expires = (datetime.now(UTC) + timedelta(days=7)).isoformat()
|
||||||
"headers": {"Cookie": f"better-auth.session_token={session_token}"},
|
|
||||||
}
|
async with db_engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
|
||||||
|
"VALUES (:id, :token, :user_id, :expires_at, :created_at, :updated_at)"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"token": session_token,
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"expires_at": expires,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user": user,
|
||||||
|
"store": store,
|
||||||
|
"purchase": purchase,
|
||||||
|
"headers": {"Cookie": f"better-auth.session_token={session_token}"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user