forked from cartsnitch/cartsnitch
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa893d9cc1 | |||
| 91c062130c | |||
| 0aef2455fd | |||
| 6602b8c105 | |||
| dbbc8d2e7b | |||
| 1267caf43c | |||
| 015401861a | |||
| 9891e1aefb | |||
| 69ad161e36 | |||
| 485f890df3 | |||
| bf3ed0ede3 | |||
| 3f41eb7346 | |||
| 6cbd1ef298 | |||
| 94214f762e | |||
| 562c6ef6f6 | |||
| ccc8189d88 | |||
| 86594e4a8e | |||
| c2f1a83c1d | |||
| 6f8e5a9577 | |||
| bbfa816e57 | |||
| 5904eb03a2 | |||
| 87b6433ff7 | |||
| d7c9938f7e | |||
| 02434060ee |
@@ -1,51 +1,26 @@
|
||||
"""Redis/DragonflyDB caching helpers."""
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from cartsnitch_api.config import settings
|
||||
|
||||
|
||||
class CacheClient:
|
||||
"""Redis/DragonflyDB caching with connection pooling.
|
||||
"""Stub for Redis/DragonflyDB caching.
|
||||
|
||||
Will be used for expensive queries: price trends, product comparisons.
|
||||
Cache invalidation via Redis pub/sub events from other services.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pool: redis.ConnectionPool | None = None
|
||||
self._client: redis.Redis | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the Redis connection pool."""
|
||||
self._pool = redis.ConnectionPool.from_url(
|
||||
settings.redis_url,
|
||||
max_connections=20,
|
||||
decode_responses=True,
|
||||
)
|
||||
self._client = redis.Redis(connection_pool=self._pool)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the Redis connection pool."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
if self._pool:
|
||||
await self._pool.aclose()
|
||||
self.url = settings.redis_url
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
if not self._client:
|
||||
return None
|
||||
return await self._client.get(key)
|
||||
# TODO: implement with redis-py async
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
|
||||
if not self._client:
|
||||
return
|
||||
await self._client.set(key, value, ex=ttl_seconds)
|
||||
# TODO: implement with redis-py async
|
||||
pass
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
if not self._client:
|
||||
return
|
||||
await self._client.delete(key)
|
||||
|
||||
|
||||
cache_client = CacheClient()
|
||||
# TODO: implement with redis-py async
|
||||
pass
|
||||
|
||||
@@ -6,14 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
|
||||
from cartsnitch_api.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@@ -21,8 +14,3 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency that yields an async DB session."""
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def dispose_engine() -> None:
|
||||
"""Dispose the database engine, closing all pooled connections."""
|
||||
await engine.dispose()
|
||||
|
||||
@@ -5,8 +5,6 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import APIRouter, FastAPI
|
||||
|
||||
from cartsnitch_api.auth.routes import router as auth_router
|
||||
from cartsnitch_api.cache import cache_client
|
||||
from cartsnitch_api.database import dispose_engine
|
||||
from cartsnitch_api.middleware.cors import add_cors_middleware
|
||||
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
|
||||
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
|
||||
@@ -25,10 +23,9 @@ from cartsnitch_api.routes.user import router as user_router
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await cache_client.initialize()
|
||||
# TODO: initialize DB session pool, Redis connection, service clients
|
||||
yield
|
||||
await cache_client.close()
|
||||
await dispose_engine()
|
||||
# TODO: cleanup connections
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
||||
+6
-12
@@ -4,23 +4,17 @@ import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
||||
});
|
||||
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.warn(
|
||||
"WARNING: DATABASE_URL is not set — using default localhost connection. " +
|
||||
"Set DATABASE_URL for production deployments."
|
||||
);
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl ?? "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: pool,
|
||||
basePath: "/auth",
|
||||
|
||||
+1
-88
@@ -1,4 +1,4 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
export const test = base.extend<{ axeCheck: void }>({
|
||||
@@ -10,90 +10,3 @@ export const test = base.extend<{ axeCheck: void }>({
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
|
||||
const MOCK_USER_ID = "mock_user_123";
|
||||
const MOCK_SESSION_ID = "mock_session_456";
|
||||
|
||||
function mockAuthRoutes(page: Page, authenticated = false) {
|
||||
page.route(/.*\/auth\/sign-up\/email.*/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
token: null,
|
||||
user: {
|
||||
id: MOCK_USER_ID,
|
||||
email: "mock@cartsnitch.test",
|
||||
name: "Mock User",
|
||||
emailVerified: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
page.route(/.*\/auth\/sign-in\/email.*/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
redirect: false,
|
||||
token: "mock_token_123",
|
||||
user: {
|
||||
id: MOCK_USER_ID,
|
||||
email: "mock@cartsnitch.test",
|
||||
name: "Mock User",
|
||||
emailVerified: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
page.route(/.*\/auth\/get-session.*/, async (route) => {
|
||||
if (authenticated) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
session: {
|
||||
id: MOCK_SESSION_ID,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
},
|
||||
user: {
|
||||
id: MOCK_USER_ID,
|
||||
email: "mock@cartsnitch.test",
|
||||
name: "Mock User",
|
||||
emailVerified: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Unauthorized" }),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function mockSessionPending(page: Page) {
|
||||
page.route(/.*\/auth\/session.*/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Unauthorized" }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export { mockAuthRoutes };
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { mockAuthRoutes } from '../fixtures';
|
||||
|
||||
const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`;
|
||||
|
||||
test.describe('J1: Registration and Login', () => {
|
||||
test('can register a new account and see check your email screen', async ({ page }) => {
|
||||
mockAuthRoutes(page, true);
|
||||
test('can register a new account and lands on dashboard', async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
await page.fill('[placeholder="Full Name"]', 'Betty Tester');
|
||||
await page.fill('[placeholder="Email"]', uniqueEmail());
|
||||
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /check your email/i })).toBeVisible();
|
||||
// With VITE_MOCK_AUTH=true the app navigates to "/" on success
|
||||
await expect(page).toHaveURL('http://localhost:5173/');
|
||||
await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows validation error when registration fields are empty', async ({ page }) => {
|
||||
@@ -31,9 +31,22 @@ test.describe('J1: Registration and Login', () => {
|
||||
});
|
||||
|
||||
test('can sign in with credentials and land on dashboard', async ({ page }) => {
|
||||
mockAuthRoutes(page, true);
|
||||
// Register first so we have a real account
|
||||
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).toHaveURL('http://localhost:5173/');
|
||||
|
||||
// Sign out by clearing the mock session (reload with no session)
|
||||
await page.goto('/');
|
||||
await page.reload();
|
||||
|
||||
// Now sign in
|
||||
await page.goto('/login');
|
||||
await page.fill('[placeholder="Email"]', 'test@cartsnitch.test');
|
||||
await page.fill('[placeholder="Email"]', email);
|
||||
await page.fill('[placeholder="Password"]', 'TestPass123!');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { mockAuthRoutes } from '../fixtures';
|
||||
|
||||
test.describe('J8: Unauthenticated Access', () => {
|
||||
test('redirects /dashboard (/) to /login when not authenticated', async ({ page }) => {
|
||||
mockAuthRoutes(page, false);
|
||||
// No session cookie — start fresh
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/');
|
||||
|
||||
@@ -12,7 +11,6 @@ test.describe('J8: Unauthenticated Access', () => {
|
||||
});
|
||||
|
||||
test('redirects /purchases to /login when not authenticated', async ({ page }) => {
|
||||
mockAuthRoutes(page, false);
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/purchases');
|
||||
|
||||
@@ -21,7 +19,6 @@ test.describe('J8: Unauthenticated Access', () => {
|
||||
});
|
||||
|
||||
test('redirects /products to /login when not authenticated', async ({ page }) => {
|
||||
mockAuthRoutes(page, false);
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/products');
|
||||
|
||||
@@ -30,7 +27,6 @@ test.describe('J8: Unauthenticated Access', () => {
|
||||
});
|
||||
|
||||
test('redirects /coupons to /login when not authenticated', async ({ page }) => {
|
||||
mockAuthRoutes(page, false);
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/coupons');
|
||||
|
||||
@@ -39,9 +35,15 @@ test.describe('J8: Unauthenticated Access', () => {
|
||||
});
|
||||
|
||||
test('shows loading spinner while auth session is pending', async ({ page }) => {
|
||||
mockAuthRoutes(page, false);
|
||||
// Intercept but don't respond — session stays pending
|
||||
await page.context().clearCookies();
|
||||
await page.request.fetch('/api/auth/session', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
// Just navigate to a protected route — ProtectedRoute will show spinner while session is pending
|
||||
await page.goto('/purchases');
|
||||
// Spinner is visible briefly; once resolved, should redirect to login
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
import { test, expect, mockAuthRoutes } from './fixtures';
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('app loads', async ({ page }) => {
|
||||
mockAuthRoutes(page, false);
|
||||
await page.goto('/');
|
||||
// Unauthenticated users are redirected to /login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
Generated
+3
-3
@@ -9805,9 +9805,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -9,12 +9,9 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
command: 'VITE_MOCK_AUTH=true npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: {
|
||||
VITE_MOCK_AUTH: 'true',
|
||||
},
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
"""Service-specific configuration for ReceiptWitness."""
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
_PLACEHOLDER_VALUES = {"change-me-in-production"}
|
||||
|
||||
|
||||
class ReceiptWitnessSettings(BaseSettings):
|
||||
model_config = {"env_prefix": "RW_"}
|
||||
|
||||
@@ -34,34 +30,5 @@ class ReceiptWitnessSettings(BaseSettings):
|
||||
# Mailgun inbound email webhook
|
||||
mailgun_webhook_signing_key: str = ""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_required_vars(self):
|
||||
errors = []
|
||||
if not self.session_encryption_key or self.session_encryption_key in _PLACEHOLDER_VALUES:
|
||||
errors.append(
|
||||
"RW_SESSION_ENCRYPTION_KEY must be set to a secure value. "
|
||||
'Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
|
||||
)
|
||||
if self.notifications_enabled and not self.resend_api_key:
|
||||
errors.append(
|
||||
"RW_RESEND_API_KEY must be set when RW_NOTIFICATIONS_ENABLED=true. "
|
||||
"Get an API key from https://resend.com/api-keys"
|
||||
)
|
||||
if errors:
|
||||
raise ValueError(
|
||||
"ReceiptWitness startup failed — missing required config:\n"
|
||||
+ "\n".join(f" - {e}" for e in errors)
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class _LazySettings:
|
||||
_instance: ReceiptWitnessSettings | None = None
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
if _LazySettings._instance is None:
|
||||
_LazySettings._instance = ReceiptWitnessSettings()
|
||||
return getattr(_LazySettings._instance, name)
|
||||
|
||||
|
||||
settings = _LazySettings()
|
||||
settings = ReceiptWitnessSettings()
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
"""Shared test fixtures."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
os.environ.setdefault("RW_SESSION_ENCRYPTION_KEY", "test-secret-key-for-unit-tests-only-32bytes!")
|
||||
os.environ.setdefault("RW_MAILGUN_WEBHOOK_SIGNING_KEY", "test-mailgun-signing-key")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def meijer_receipt_data() -> dict:
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import pytest
|
||||
from receiptwitness.config import ReceiptWitnessSettings
|
||||
|
||||
|
||||
def test_valid_config():
|
||||
s = ReceiptWitnessSettings(
|
||||
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
|
||||
)
|
||||
assert s.session_encryption_key
|
||||
|
||||
|
||||
def test_missing_session_encryption_key_raises():
|
||||
with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"):
|
||||
ReceiptWitnessSettings(session_encryption_key="")
|
||||
|
||||
|
||||
def test_placeholder_session_encryption_key_raises():
|
||||
with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"):
|
||||
ReceiptWitnessSettings(session_encryption_key="change-me-in-production")
|
||||
|
||||
|
||||
def test_notifications_enabled_without_resend_key_raises():
|
||||
with pytest.raises(ValueError, match="RW_RESEND_API_KEY"):
|
||||
ReceiptWitnessSettings(
|
||||
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
|
||||
notifications_enabled=True,
|
||||
resend_api_key="",
|
||||
)
|
||||
|
||||
|
||||
def test_notifications_disabled_without_resend_key_ok():
|
||||
s = ReceiptWitnessSettings(
|
||||
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
|
||||
notifications_enabled=False,
|
||||
resend_api_key="",
|
||||
)
|
||||
assert s.notifications_enabled is False
|
||||
|
||||
|
||||
def test_notifications_enabled_with_resend_key_ok():
|
||||
s = ReceiptWitnessSettings(
|
||||
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
|
||||
notifications_enabled=True,
|
||||
resend_api_key="re_test_1234567890",
|
||||
)
|
||||
assert s.resend_api_key == "re_test_1234567890"
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { authClient } from '../lib/auth-client.ts'
|
||||
import { useAuthStore } from '../stores/auth.ts'
|
||||
|
||||
@@ -8,6 +8,7 @@ export function Login() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
@@ -30,12 +31,11 @@ export function Login() {
|
||||
throw new Error(authError.message ?? 'Sign in failed')
|
||||
}
|
||||
|
||||
// After successful signIn, force a full page reload so Better-Auth's
|
||||
// useSession() reinitializes with fresh cookie-backed session state.
|
||||
// Using React Router's navigate() races with Better-Auth's internal update.
|
||||
// After successful signIn, force a session fetch to confirm the cookie is set
|
||||
// before navigating to the protected route
|
||||
const sessionResult = await authClient.getSession()
|
||||
if (sessionResult.data) {
|
||||
window.location.href = '/'
|
||||
navigate('/')
|
||||
} else {
|
||||
setError('Sign in failed. Please try again.')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user