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:
+3
-4
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user