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:
CartSnitch Engineer Bot
2026-04-03 09:40:39 +00:00
parent 18ff5795ac
commit bbbf97d027
18 changed files with 360 additions and 236 deletions
+3 -4
View File
@@ -141,7 +141,6 @@ async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_o
user_id = str(uuid.uuid4())
email = user_overrides.get("email", "test@example.com")
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_id = str(uuid.uuid4())
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:
await conn.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)"
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hashed_password, :display_name, :email_verified, :created_at, :updated_at)"
),
{
"id": user_id,
"email": email,
"hashed_password": "not-used-with-better-auth",
"display_name": display_name,
"email_inbound_token": email_inbound_token,
"email_verified": False,
"created_at": now,
"updated_at": now,
},
+3 -3
View File
@@ -88,15 +88,15 @@ async def test_expired_session_rejected(client, db_engine):
async with db_engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_inbound_token, created_at, updated_at) "
"VALUES (:id, :email, :hp, :dn, :eit, :ca, :ua)"
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hp, :dn, :ev, :ca, :ua)"
),
{
"id": user_id,
"email": "expired@example.com",
"hp": "unused",
"dn": "Expired User",
"eit": secrets.token_urlsafe(16),
"ev": False,
"ca": now,
"ua": now,
},
+17 -45
View File
@@ -7,11 +7,10 @@ exercise cross-resource queries against real data.
from datetime import date, timedelta
from decimal import Decimal
import uuid
from sqlalchemy import text
from uuid import UUID
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from cartsnitch_api.models import (
@@ -27,27 +26,24 @@ from cartsnitch_api.models import (
# Shared test constants
ZERO_UUID = "00000000-0000-0000-0000-000000000000"
BAD_UUID = "not-a-uuid"
# Anchor date relative to today so coupon validity windows stay in the future
ANCHOR_DATE = date.today()
# Fixed anchor date for deterministic tests
ANCHOR_DATE = date(2026, 3, 15)
@pytest.fixture
async def seed_data(db_engine, auth_headers):
"""Seed a full dataset and return identifiers for test assertions."""
import uuid
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
# -- Stores --
meijer = Store(name="Meijer", slug="meijer", id=uuid.uuid4())
kroger = Store(name="Kroger", slug="kroger", id=uuid.uuid4())
target = Store(name="Target", slug="target", id=uuid.uuid4())
meijer = Store(name="Meijer", slug="meijer")
kroger = Store(name="Kroger", slug="kroger")
target = Store(name="Target", slug="target")
session.add_all([meijer, kroger, target])
await session.flush()
# -- Products --
cheerios = NormalizedProduct(
id=uuid.uuid4(),
canonical_name="Cheerios 18oz",
category="pantry",
brand="General Mills",
@@ -56,7 +52,6 @@ async def seed_data(db_engine, auth_headers):
upc_variants=["016000275263"],
)
milk = NormalizedProduct(
id=uuid.uuid4(),
canonical_name="Whole Milk 1gal",
category="dairy",
brand="Meijer",
@@ -64,7 +59,6 @@ async def seed_data(db_engine, auth_headers):
size_unit="gal",
)
chicken = NormalizedProduct(
id=uuid.uuid4(),
canonical_name="Chicken Breast 1lb",
category="meat",
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")]):
prices.append(
PriceHistory(
id=uuid.uuid4(),
normalized_product_id=cheerios.id,
store_id=meijer.id,
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):
prices.append(
PriceHistory(
id=uuid.uuid4(),
normalized_product_id=cheerios.id,
store_id=kroger.id,
observed_date=today - timedelta(days=60 - i * 30),
@@ -104,7 +96,6 @@ async def seed_data(db_engine, auth_headers):
# Milk at Meijer
prices.append(
PriceHistory(
id=uuid.uuid4(),
normalized_product_id=milk.id,
store_id=meijer.id,
observed_date=today - timedelta(days=7),
@@ -115,7 +106,6 @@ async def seed_data(db_engine, auth_headers):
# Milk at Kroger
prices.append(
PriceHistory(
id=uuid.uuid4(),
normalized_product_id=milk.id,
store_id=kroger.id,
observed_date=today - timedelta(days=5),
@@ -126,7 +116,6 @@ async def seed_data(db_engine, auth_headers):
# Chicken at Target
prices.append(
PriceHistory(
id=uuid.uuid4(),
normalized_product_id=chicken.id,
store_id=target.id,
observed_date=today - timedelta(days=3),
@@ -137,29 +126,19 @@ async def seed_data(db_engine, auth_headers):
session.add_all(prices)
await session.flush()
# -- Purchases (need the user_id from the registered test user) --
# Extract session_token from auth_headers, then look up the real user_id
import http.cookies
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")
# -- Get the user_id from the session token in auth_headers --
cookie_str = auth_headers.get("Cookie", "")
session_token = cookie_str.split("=", 1)[1] if "=" in cookie_str else ""
# Look up the real user_id from the sessions table
row = await session.execute(
result = await session.execute(
text("SELECT user_id FROM sessions WHERE token = :token"),
{"token": session_token}
{"token": session_token},
)
session_row = row.fetchone()
if session_row is None:
raise RuntimeError("Session not found for session token in auth_headers")
real_user_id = session_row[0]
row = result.first()
user_id = UUID(row[0])
purchase1 = Purchase(
id=uuid.uuid4(),
user_id=uuid.UUID(real_user_id),
user_id=user_id,
store_id=meijer.id,
receipt_id="meijer-2026-001",
purchase_date=today - timedelta(days=10),
@@ -168,8 +147,7 @@ async def seed_data(db_engine, auth_headers):
tax=Decimal("1.95"),
)
purchase2 = Purchase(
id=uuid.uuid4(),
user_id=uuid.UUID(real_user_id),
user_id=user_id,
store_id=kroger.id,
receipt_id="kroger-2026-001",
purchase_date=today - timedelta(days=5),
@@ -182,7 +160,6 @@ async def seed_data(db_engine, auth_headers):
# -- Purchase Items --
item1 = PurchaseItem(
id=uuid.uuid4(),
purchase_id=purchase1.id,
product_name_raw="Cheerios 18oz Box",
quantity=Decimal("1"),
@@ -191,7 +168,6 @@ async def seed_data(db_engine, auth_headers):
normalized_product_id=cheerios.id,
)
item2 = PurchaseItem(
id=uuid.uuid4(),
purchase_id=purchase1.id,
product_name_raw="Meijer Whole Milk 1gal",
quantity=Decimal("2"),
@@ -200,7 +176,6 @@ async def seed_data(db_engine, auth_headers):
normalized_product_id=milk.id,
)
item3 = PurchaseItem(
id=uuid.uuid4(),
purchase_id=purchase2.id,
product_name_raw="KRO CHEERIOS 18OZ",
quantity=Decimal("1"),
@@ -213,7 +188,6 @@ async def seed_data(db_engine, auth_headers):
# -- Coupons --
coupon1 = Coupon(
id=uuid.uuid4(),
store_id=meijer.id,
normalized_product_id=cheerios.id,
title="$1 off Cheerios",
@@ -224,7 +198,6 @@ async def seed_data(db_engine, auth_headers):
valid_to=today + timedelta(days=30),
)
coupon2 = Coupon(
id=uuid.uuid4(),
store_id=kroger.id,
normalized_product_id=None,
title="10% off dairy",
@@ -239,7 +212,6 @@ async def seed_data(db_engine, auth_headers):
# -- Shrinkflation events --
shrink = ShrinkflationEvent(
id=uuid.uuid4(),
normalized_product_id=cheerios.id,
detected_date=today - timedelta(days=15),
old_size="20",
@@ -274,7 +246,7 @@ async def seed_data(db_engine, auth_headers):
return {
"headers": auth_headers,
"user_id": real_user_id,
"user_id": user_id,
"stores": {"meijer": meijer, "kroger": kroger, "target": target},
"products": {"cheerios": cheerios, "milk": milk, "chicken": chicken},
"purchases": {"meijer_trip": purchase1, "kroger_trip": purchase2},
+3 -3
View File
@@ -65,15 +65,15 @@ class TestSessionValidation:
async with db_engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_inbound_token, created_at, updated_at) "
"VALUES (:id, :email, :hp, :dn, :eit, :ca, :ua)"
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hp, :dn, :ev, :ca, :ua)"
),
{
"id": user_id,
"email": "expired@e2e.com",
"hp": "unused",
"dn": "Expired User",
"eit": secrets.token_urlsafe(16),
"ev": False,
"ca": now,
"ua": now,
},
+68
View File
@@ -5,6 +5,74 @@ import pytest
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
class TestNotFoundErrors:
"""404 responses for missing resources."""
+4 -5
View File
@@ -15,12 +15,11 @@ async def test_404_returns_structured_error(client):
@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."""
resp = await client.patch(
"/auth/me",
headers=auth_headers,
json={"display_name": ""},
resp = await client.post(
"/auth/register",
json={"email": "not-an-email", "password": "short", "display_name": ""},
)
assert resp.status_code == 422
body = resp.json()
+5 -2
View File
@@ -6,7 +6,10 @@ from httpx import ASGITransport, AsyncClient
from cartsnitch_api.main import app
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"),
("patch", "/auth/me"),
("delete", "/auth/me"),
@@ -87,4 +90,4 @@ async def test_route_count():
if method in ("get", "post", "put", "delete", "patch"):
count += 1
assert count == 31, f"Expected 31 routes, found {count}"
assert count == 34, f"Expected 34 routes, found {count}"
+41 -57
View File
@@ -2,81 +2,44 @@
import secrets
import uuid
from datetime import UTC, datetime, date, timedelta
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
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
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)
async with factory() as session:
user_id = str(uuid.uuid4())
session_token = secrets.token_urlsafe(32)
now = datetime.now(UTC).isoformat()
expires = (datetime.now(UTC) + timedelta(days=7)).isoformat()
# 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,
},
user = User(
email="buyer@example.com",
hashed_password="not-used-with-better-auth",
display_name="Buyer",
)
# Create the session
await session.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": 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()
store = Store(name="Kroger", slug="kroger")
session.add_all([user, store])
await session.commit()
await session.refresh(user)
await session.refresh(store)
# Create the purchase
purchase = Purchase(
id=uuid.uuid4(),
user_id=uuid.UUID(user_id),
user_id=user.id,
store_id=store.id,
receipt_id="receipt-001",
purchase_date=date(2026, 3, 10),
total=Decimal("42.50"),
)
session.add(purchase)
await session.flush()
await session.commit()
await session.refresh(purchase)
# Create the purchase item
item = PurchaseItem(
id=uuid.uuid4(),
purchase_id=purchase.id,
product_name_raw="Organic Milk 1gal",
quantity=Decimal("1"),
@@ -86,12 +49,33 @@ async def purchase_data(db_engine):
session.add(item)
await session.commit()
return {
"user_id": user_id,
"store": store,
"purchase": purchase,
"headers": {"Cookie": f"better-auth.session_token={session_token}"},
}
# Create a session token directly in the sessions table
session_token = secrets.token_urlsafe(32)
now = datetime.now(UTC).isoformat()
expires = (datetime.now(UTC) + timedelta(days=7)).isoformat()
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