Compare commits

..

2 Commits

Author SHA1 Message Date
savannah-savings-cto[bot] 60beb2d89e Merge pull request #237 from cartsnitch/uat
release: remove auth image build from monorepo CI (CAR-749)
2026-04-20 18:53:47 +00:00
savannah-savings-cto[bot] 9120c834e4 Merge pull request #236 from cartsnitch/dev
Promote dev to UAT: remove auth image build from CI
2026-04-20 18:01:29 +00:00
18 changed files with 86 additions and 350 deletions
-32
View File
@@ -1,41 +1,9 @@
"""Redis/DragonflyDB caching helpers."""
import logging
from typing import TYPE_CHECKING
import redis.asyncio as redis
from redis.asyncio import Redis
from cartsnitch_api.config import settings
if TYPE_CHECKING:
from cartsnitch_api.config import Settings
logger = logging.getLogger(__name__)
_redis: "Redis | None" = None
def get_settings() -> "Settings":
return settings
async def init_redis() -> None:
global _redis
_redis = redis.from_url(settings.redis_url)
await _redis.ping()
async def close_redis() -> None:
global _redis
if _redis is not None:
await _redis.aclose()
_redis = None
def get_redis() -> Redis | None:
return _redis
class CacheClient:
"""Redis/DragonflyDB caching with connection pooling.
+13 -45
View File
@@ -1,60 +1,28 @@
"""Database session management for the API gateway."""
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from cartsnitch_api.config import settings
if TYPE_CHECKING:
from sqlalchemy.engine import Engine
_engine: "Engine | None" = None
async_session_factory: async_sessionmaker[AsyncSession] | None = None
def create_db_engine():
return create_async_engine(
settings.database_url,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
echo=False,
)
async def init_db() -> None:
global _engine, async_session_factory
_engine = create_db_engine()
async_session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)
async def close_db() -> None:
global _engine, async_session_factory
if _engine is not None:
await _engine.dispose()
_engine = None
async_session_factory = None
def get_engine():
return _engine
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
)
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
if async_session_factory is None:
raise RuntimeError("Database not initialized. Call init_db() first.")
"""FastAPI dependency that yields an async DB session."""
async with async_session_factory() as session:
yield session
# Backward compatibility: module-level engine proxy that delegates to _engine
def __getattr__(name: str):
if name == "engine":
if _engine is None:
raise RuntimeError("Database not initialized. Call init_db() first.")
return _engine
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
async def dispose_engine() -> None:
"""Dispose the database engine, closing all pooled connections."""
await engine.dispose()
+3 -7
View File
@@ -26,14 +26,10 @@ from cartsnitch_api.routes.user import router as user_router
@asynccontextmanager
async def lifespan(app: FastAPI):
from cartsnitch_api.database import init_db, close_db
from cartsnitch_api.cache import init_redis, close_redis
await init_db()
await init_redis()
await cache_client.initialize()
yield
await close_redis()
await close_db()
await cache_client.close()
await dispose_engine()
def create_app() -> FastAPI:
+1 -24
View File
@@ -1,11 +1,8 @@
"""Health check and error metrics endpoints."""
from fastapi import APIRouter, Depends
from sqlalchemy import text
from cartsnitch_api.auth.dependencies import verify_service_key
from cartsnitch_api.cache import get_redis
from cartsnitch_api.database import get_engine
from cartsnitch_api.middleware.error_handler import get_error_monitor
router = APIRouter(tags=["health"])
@@ -13,27 +10,7 @@ router = APIRouter(tags=["health"])
@router.get("/health")
async def health():
engine = get_engine()
db_ok = False
redis_ok = False
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
db_ok = True
except Exception:
pass
try:
r = get_redis()
if r:
await r.ping()
redis_ok = True
except Exception:
pass
status = "ok" if db_ok else "degraded"
return {"status": status, "db": db_ok, "redis": redis_ok}
return {"status": "ok"}
@router.get("/internal/error-stats", dependencies=[Depends(verify_service_key)])
-50
View File
@@ -1,50 +0,0 @@
"""Tests for Redis/DragonflyDB caching lifecycle."""
import pytest
from cartsnitch_api.cache import CacheClient, close_redis, get_redis, init_redis
@pytest.mark.asyncio
async def test_init_redis_creates_client():
"""Test that init_redis creates the Redis client."""
await init_redis()
try:
r = get_redis()
assert r is not None
await r.ping()
finally:
await close_redis()
@pytest.mark.asyncio
async def test_close_redis_clears_client():
"""Test that close_redis properly closes and clears the client."""
await init_redis()
await close_redis()
assert get_redis() is None
@pytest.mark.asyncio
async def test_cache_client_get_returns_none_when_not_connected():
"""Test that CacheClient.get returns None gracefully when Redis is down."""
client = CacheClient()
# Without init_redis, get should return None
result = await client.get("test-key")
assert result is None
@pytest.mark.asyncio
async def test_cache_client_set_does_not_raise_when_not_connected():
"""Test that CacheClient.set does not raise when Redis is down."""
client = CacheClient()
# Without init_redis, set should not raise
await client.set("test-key", "test-value", ttl_seconds=60)
@pytest.mark.asyncio
async def test_cache_client_delete_does_not_raise_when_not_connected():
"""Test that CacheClient.delete does not raise when Redis is down."""
client = CacheClient()
# Without init_redis, delete should not raise
await client.delete("test-key")
-62
View File
@@ -1,62 +0,0 @@
"""Tests for database initialization and lifecycle."""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from cartsnitch_api.database import (
close_db,
create_db_engine,
get_engine,
init_db,
)
@pytest.mark.asyncio
async def test_create_db_engine_creates_engine_with_pool_settings():
"""Test that create_db_engine creates engine with correct pool settings."""
engine = create_db_engine()
assert engine is not None
pool = engine.pool
assert pool.size() == 10
assert pool._max_overflow == 20
await engine.dispose()
@pytest.mark.asyncio
async def test_init_db_sets_engine_and_factory():
"""Test that init_db properly initializes the engine and session factory."""
await init_db()
try:
eng = get_engine()
assert eng is not None
from cartsnitch_api import database
assert database.async_session_factory is not None
finally:
await close_db()
@pytest.mark.asyncio
async def test_close_db_disposes_engine():
"""Test that close_db properly disposes the engine."""
await init_db()
await close_db()
assert get_engine() is None
from cartsnitch_api import database
assert database.async_session_factory is None
@pytest.mark.asyncio
async def test_get_db_yields_session_after_init():
"""Test that get_db yields working sessions after init_db."""
await init_db()
try:
from cartsnitch_api.database import get_db
gen = get_db()
session = await gen.__anext__()
assert isinstance(session, AsyncSession)
await gen.aclose()
finally:
await close_db()
-77
View File
@@ -1,77 +0,0 @@
"""Tests for health check endpoint."""
import pytest
from unittest.mock import AsyncMock, patch
from cartsnitch_api.database import init_db, close_db
@pytest.mark.asyncio
async def test_health_returns_db_and_redis_fields(client):
"""Test that health endpoint returns db and redis status fields."""
from cartsnitch_api.cache import init_redis, close_redis
await init_db()
await init_redis()
try:
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert "status" in data
assert "db" in data
assert "redis" in data
finally:
await close_redis()
await close_db()
@pytest.mark.asyncio
async def test_health_returns_degraded_when_db_down():
"""Test that health returns degraded when database is down."""
from cartsnitch_api.database import _engine
from cartsnitch_api.routes.health import health
# Simulate engine is None (DB not initialized)
with patch("cartsnitch_api.routes.health.get_engine", return_value=None):
response = await health()
assert response["status"] == "degraded"
assert response["db"] is False
@pytest.mark.asyncio
async def test_health_returns_ok_when_db_up(client):
"""Test that health returns ok when database is up."""
from cartsnitch_api.database import init_db, close_db
from cartsnitch_api.cache import init_redis, close_redis
await init_db()
await init_redis()
try:
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
if data["db"]:
assert data["status"] == "ok"
finally:
await close_redis()
await close_db()
@pytest.mark.asyncio
async def test_health_redis_down_does_not_make_unhealthy(client):
"""Test that Redis being down does not make health return unhealthy."""
from cartsnitch_api.database import init_db, close_db
await init_db()
try:
response = await client.get("/health")
data = response.json()
# Redis being down should not make status "degraded"
# Only DB failure makes it degraded
if not data["db"]:
assert data["status"] == "degraded"
finally:
await close_db()
+1 -1
View File
@@ -12,5 +12,5 @@
"resolveJsonModule": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "src/__tests__"]
"exclude": ["node_modules", "dist"]
}
@@ -1,28 +0,0 @@
"""Add GIN index on normalized_products.upc_variants for fast JSON containment lookups.
Revision ID: 002_add_normalized_products_upc_variants_index
Revises: 001_add_email_inbound_token
Create Date: 2026-04-14
"""
from collections.abc import Sequence
from alembic import op
revision: str = "002_add_normalized_products_upc_variants_index"
down_revision: str | None = "001_add_email_inbound_token"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_index(
"ix_normalized_products_upc_variants",
"normalized_products",
["upc_variants"],
postgresql_using="gin",
)
def downgrade() -> None:
op.drop_index("ix_normalized_products_upc_variants", table_name="normalized_products")
+3 -12
View File
@@ -4,7 +4,7 @@ import { mockAuthRoutes } from '../fixtures';
const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`;
test.describe('J1: Registration and Login', () => {
test('shows success message after registration', async ({ page }) => {
test('can register a new account and see check your email screen', async ({ page }) => {
await mockAuthRoutes(page, false);
await page.goto('/register');
await page.fill('[placeholder="Full Name"]', 'Betty Tester');
@@ -12,8 +12,7 @@ test.describe('J1: Registration and Login', () => {
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
await page.click('button[type="submit"]');
// Registration now shows "Account created! Please sign in." message
await expect(page.locator('.bg-red-50')).toContainText('Account created! Please sign in.');
await expect(page.getByRole('heading', { name: /check your email/i })).toBeVisible();
});
test('shows validation error when registration fields are empty', async ({ page }) => {
@@ -31,16 +30,8 @@ test.describe('J1: Registration and Login', () => {
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('can sign in with valid credentials', async ({ page }) => {
test('can sign in with credentials and land on dashboard', async ({ page }) => {
await mockAuthRoutes(page, true);
const email = uniqueEmail();
await page.goto('/register');
await page.fill('[placeholder="Full Name"]', 'Login Betty');
await page.fill('[placeholder="Email"]', email);
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
await page.click('button[type="submit"]');
await expect(page.locator('.bg-red-50')).toContainText('Account created! Please sign in.');
await page.goto('/login');
await page.fill('[placeholder="Email"]', 'test@cartsnitch.test');
await page.fill('[placeholder="Password"]', 'TestPass123!');
+3 -3
View File
@@ -8164,9 +8164,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"devOptional": true,
"funding": [
{
+2 -2
View File
@@ -126,7 +126,7 @@ function AlertCard({
</Link>
<div className="mt-1 flex items-center gap-2">
<span className="text-xs text-gray-500">Target: ${alert.targetPrice.toFixed(2)}</span>
<span className="text-xs text-gray-500">&middot;</span>
<span className="text-xs text-gray-400">&middot;</span>
<span className={`text-xs font-medium ${isBelow ? 'text-green-700' : 'text-gray-500'}`}>
Now: ${alert.currentPrice.toFixed(2)}
</span>
@@ -145,7 +145,7 @@ function AlertCard({
)}
<button
onClick={() => onDelete(alert.id)}
className="min-h-12 min-w-12 rounded-lg p-2 text-gray-500 active:bg-gray-100"
className="min-h-12 min-w-12 rounded-lg p-2 text-gray-400 active:bg-gray-100"
aria-label="Delete alert"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+1 -1
View File
@@ -62,7 +62,7 @@ export function Coupons() {
<p className="mt-0.5 text-xs text-gray-500">{coupon.storeName}</p>
<p
className={`mt-1 text-xs ${
expiringSoon ? 'font-medium text-orange-600' : 'text-gray-500'
expiringSoon ? 'font-medium text-orange-600' : 'text-gray-400'
}`}
>
Expires{' '}
+8 -1
View File
@@ -1,12 +1,14 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
export function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -38,7 +40,12 @@ export function Login() {
setError('Sign in failed. Please try again.')
}
} catch {
setError('Invalid email or password. Please try again.')
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true)
window.location.href = '/'
} else {
setError('Invalid email or password. Please try again.')
}
} finally {
setLoading(false)
}
+1 -1
View File
@@ -97,7 +97,7 @@ export function Purchases() {
</div>
{/* Item preview */}
<p className="mt-2 truncate text-xs text-gray-500">
<p className="mt-2 truncate text-xs text-gray-400">
{purchase.items
.slice(0, 3)
.map((i) => i.name)
+47 -1
View File
@@ -8,6 +8,9 @@ export function Register() {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [registrationComplete, setRegistrationComplete] = useState(false)
const [resendLoading, setResendLoading] = useState(false)
const [resendMessage, setResendMessage] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -35,7 +38,7 @@ export function Register() {
throw new Error(authError.message ?? 'Registration failed')
}
setError('Account created! Please sign in.')
setRegistrationComplete(true)
} catch {
setError('Registration failed. Please try again.')
} finally {
@@ -43,6 +46,49 @@ export function Register() {
}
}
async function handleResendVerification() {
setResendLoading(true)
setResendMessage('')
try {
const { error } = await authClient.sendVerificationEmail({ email })
if (error) {
setResendMessage('Failed to resend. Please try again.')
} else {
setResendMessage('Verification email sent!')
}
} finally {
setResendLoading(false)
}
}
if (registrationComplete) {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<h1 className="mb-2 text-3xl font-bold text-gray-900">Check your email</h1>
<p className="mb-8 text-sm text-gray-500">
We sent a verification link to {email}. Click it to activate your account.
</p>
<button
type="button"
onClick={handleResendVerification}
disabled={resendLoading}
className="min-h-12 rounded-xl bg-brand-blue px-6 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
>
{resendLoading ? 'Sending...' : 'Resend email'}
</button>
{resendMessage && (
<p className="mt-4 text-sm text-gray-500">{resendMessage}</p>
)}
<p className="mt-6 text-sm text-gray-500">
Already have an account?{' '}
<Link to="/login" className="text-brand-blue">
Sign in
</Link>
</p>
</div>
)
}
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<h1 className="mb-2 text-3xl font-bold text-gray-900">Create Account</h1>
+1 -1
View File
@@ -153,7 +153,7 @@ export function Settings() {
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<p className="mt-2 text-xs text-gray-500">
<p className="mt-2 text-xs text-gray-400">
Supports Meijer, Kroger, and Target receipt emails.
</p>
</div>
+2 -2
View File
@@ -89,7 +89,7 @@ export function StoreComparison() {
{pp.price === lowestPrice ? (
<span className="text-xs font-medium text-green-600">Best price</span>
) : (
<span className="text-xs text-gray-500">
<span className="text-xs text-gray-400">
+${(pp.price - lowestPrice).toFixed(2)}
</span>
)}
@@ -99,7 +99,7 @@ export function StoreComparison() {
))}
</div>
<p className="mt-6 text-center text-xs text-gray-500">
<p className="mt-6 text-center text-xs text-gray-400">
Prices last verified from store loyalty card data. Map view coming soon.
</p>
</div>