diff --git a/README.md b/README.md
index 3191c80..a41ffd2 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,17 @@
This will run `setup.py` to create a `cost_sharing` package
+## Running the Application
+
+From the project root directory (where `setup.py` is located):
+
+```bash
+python -m cost_sharing.app
+```
+
+The app will start on `http://localhost:8000`
+
+
## Testing
Test can be run in the root of the project or from the `tests` folder. They cannot be run in `src` or its subfolders.
diff --git a/src/cost_sharing/app.py b/src/cost_sharing/app.py
index 8156adb..b22f75a 100644
--- a/src/cost_sharing/app.py
+++ b/src/cost_sharing/app.py
@@ -1,18 +1,195 @@
-from flask import Flask
+import os
+import sys
+import functools
+from flask import Flask, request, jsonify, g, render_template
+from dotenv import load_dotenv
+from cost_sharing.oauth_handler import (
+ OAuthHandler, OAuthCodeError, OAuthVerificationError,
+ TokenExpiredError, TokenInvalidError
+)
+from cost_sharing.storage import InMemoryCostStorage
+from cost_sharing.cost_sharing import CostSharing
+from cost_sharing.exceptions import UserNotFoundError
-def create_app():
+
+# Ignore "too-many-statements" because this function is going to be long!
+def create_app(oauth_handler, application): # pylint: disable=R0915
+ """
+ Create and configure Flask application.
+
+ Args:
+ oauth_handler: OAuthHandler instance for OAuth operations
+ application: CostSharing application layer instance
+
+ Returns:
+ Configured Flask application
+ """
app = Flask(__name__)
+ def require_auth(f):
+ """
+ Decorator to require JWT authentication for a route.
+
+ Extracts and validates JWT token from Authorization header.
+ Stores user_id in Flask's g object for use in the route handler.
+ Returns 401 Unauthorized if authentication fails.
+ """
+ @functools.wraps(f)
+ def decorated_function(*args, **kwargs):
+ # Extract Authorization header
+ auth_header = request.headers.get('Authorization')
+ if not auth_header:
+ return jsonify({
+ "error": "Unauthorized",
+ "message": "Authentication required"
+ }), 401
+
+ # Check if header starts with "Bearer "
+ if not auth_header.startswith('Bearer '):
+ return jsonify({
+ "error": "Unauthorized",
+ "message": "Authentication required"
+ }), 401
+
+ # Extract token
+ token = auth_header[7:] # Remove "Bearer " prefix
+
+ try:
+ # Validate token and get user_id
+ user_id = oauth_handler.validate_jwt_token(token)
+ # Store user_id in g for use in route handler
+ g.user_id = user_id
+ except (TokenExpiredError, TokenInvalidError):
+ return jsonify({
+ "error": "Unauthorized",
+ "message": "Authentication required"
+ }), 401
+
+ # Call the original route function
+ return f(*args, **kwargs)
+
+ return decorated_function
+
@app.route('/')
def index():
- return "Hello, World!"
+ """Serve the demo page."""
+ return render_template('index.html')
+
+ @app.route('/auth/login', methods=['GET'])
+ def auth_login():
+ """
+ Get Google OAuth authorization URL.
+
+ Returns the URL that the frontend should redirect to for Google OAuth login.
+ """
+ authorization_url, state = oauth_handler.get_authorization_url()
+ return jsonify({
+ "url": authorization_url,
+ "state": state
+ }), 200
+
+ @app.route('/auth/callback', methods=['GET'])
+ def auth_callback():
+ """
+ Handle OAuth callback from Google.
+
+ Exchanges authorization code for user info, creates/gets user, and returns JWT token.
+ Called by JavaScript fetch after Google redirects to main page with code.
+ """
+ # Extract code from query parameters
+ code = request.args.get('code')
+ if not code:
+ return jsonify({
+ "error": "Validation failed",
+ "message": "code parameter is required"
+ }), 400
+
+ try:
+ # Exchange OAuth code for user information
+ user_info = oauth_handler.exchange_code_for_user_info(code)
+ email = user_info['email']
+ name = user_info['name']
+
+ # Get or create user in the application
+ user = application.get_or_create_user(email, name)
+
+ # Create JWT token for the user
+ token = oauth_handler.create_jwt_token(user.id)
+
+ # Return token and user information as JSON
+ return jsonify({
+ "token": token,
+ "user": {
+ "id": user.id,
+ "email": user.email,
+ "name": user.name
+ }
+ }), 200
+
+ except OAuthCodeError:
+ return jsonify({
+ "error": "Validation failed",
+ "message": "Invalid or expired authorization code"
+ }), 400
+
+ except OAuthVerificationError:
+ return jsonify({
+ "error": "Unauthorized",
+ "message": "OAuth verification failed"
+ }), 401
+
+ @app.route('/auth/me', methods=['GET'])
+ @require_auth
+ def auth_me():
+ """
+ Get current authenticated user information.
+
+ Requires valid JWT token in Authorization header.
+ Returns user information (id, email, name).
+ """
+ try:
+ # Get user_id from g (set by require_auth decorator)
+ user_id = g.user_id
+
+ # Get user from application layer
+ user = application.get_user_by_id(user_id)
+
+ # Return user information
+ return jsonify({
+ "id": user.id,
+ "email": user.email,
+ "name": user.name
+ }), 200
+
+ except UserNotFoundError:
+ return jsonify({
+ "error": "Resource not found",
+ "message": "User not found"
+ }), 404
return app
# This is "main" for the gunicorn launch and cannot be tested directly
def launch(): # pragma: no cover
- return create_app()
+ """Launch the Flask application with environment configuration."""
+ # Load environment variables from .env file
+ load_dotenv()
+
+ # Validate required environment variables
+ for var in ['BASE_URL', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'JWT_SECRET']:
+ if not os.getenv(var):
+ print(f"Error: Missing required environment variable: {var}", file=sys.stderr)
+ sys.exit(1)
+
+ oauth_handler = OAuthHandler(
+ base_url=os.getenv('BASE_URL'),
+ google_client_id=os.getenv('GOOGLE_CLIENT_ID'),
+ google_client_secret=os.getenv('GOOGLE_CLIENT_SECRET'),
+ jwt_secret=os.getenv('JWT_SECRET')
+ )
+
+ return create_app(oauth_handler, CostSharing(InMemoryCostStorage()))
# This is "main" for the local launch and can be tested directly
diff --git a/src/cost_sharing/oauth_handler.py b/src/cost_sharing/oauth_handler.py
index 17371a1..32c7aa9 100644
--- a/src/cost_sharing/oauth_handler.py
+++ b/src/cost_sharing/oauth_handler.py
@@ -39,7 +39,7 @@ def __init__(self, base_url, google_client_id, google_client_secret, jwt_secret)
google_client_secret: Google OAuth client secret
jwt_secret: Secret key for JWT token signing
"""
- self.redirect_uri = f"{base_url}/auth/callback"
+ self.redirect_uri = f"{base_url}/"
self.google_client_id = google_client_id
self.google_client_secret = google_client_secret
self.jwt_secret = jwt_secret
diff --git a/src/cost_sharing/static/css/style.css b/src/cost_sharing/static/css/style.css
new file mode 100644
index 0000000..88433ab
--- /dev/null
+++ b/src/cost_sharing/static/css/style.css
@@ -0,0 +1,130 @@
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ max-width: 600px;
+ margin: 50px auto;
+ padding: 20px;
+ background-color: #f5f5f5;
+}
+
+.container {
+ background: white;
+ border-radius: 8px;
+ padding: 30px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+h1 {
+ margin-top: 0;
+ color: #333;
+}
+
+.status {
+ padding: 12px;
+ border-radius: 4px;
+ margin-bottom: 20px;
+ font-weight: 500;
+}
+
+.status.logged-in {
+ background-color: #d4edda;
+ color: #155724;
+ border: 1px solid #c3e6cb;
+}
+
+.status.logged-out {
+ background-color: #f8d7da;
+ color: #721c24;
+ border: 1px solid #f5c6cb;
+}
+
+.user-info {
+ background-color: #f8f9fa;
+ padding: 20px;
+ border-radius: 4px;
+ margin-bottom: 20px;
+}
+
+.user-info h2 {
+ margin-top: 0;
+ font-size: 1.2em;
+ color: #495057;
+}
+
+.user-info p {
+ margin: 8px 0;
+ color: #6c757d;
+}
+
+.user-info strong {
+ color: #212529;
+}
+
+.token-display {
+ margin-top: 15px;
+ padding-top: 15px;
+ border-top: 1px solid #dee2e6;
+}
+
+.token-display summary {
+ cursor: pointer;
+ color: #6c757d;
+ font-size: 0.9em;
+ user-select: none;
+}
+
+.token-display summary:hover {
+ color: #495057;
+}
+
+.token-display code {
+ display: block;
+ margin-top: 8px;
+ padding: 8px;
+ background-color: #e9ecef;
+ border-radius: 4px;
+ font-size: 0.85em;
+ word-break: break-all;
+ color: #212529;
+}
+
+button {
+ background-color: #007bff;
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 1em;
+ margin-right: 10px;
+ margin-bottom: 10px;
+}
+
+button:hover {
+ background-color: #0056b3;
+}
+
+button:active {
+ background-color: #004085;
+}
+
+button.logout {
+ background-color: #dc3545;
+}
+
+button.logout:hover {
+ background-color: #c82333;
+}
+
+.error {
+ background-color: #f8d7da;
+ color: #721c24;
+ padding: 12px;
+ border-radius: 4px;
+ margin-bottom: 20px;
+ border: 1px solid #f5c6cb;
+}
+
+.hidden {
+ display: none;
+}
+
diff --git a/src/cost_sharing/static/js/script.js b/src/cost_sharing/static/js/script.js
new file mode 100644
index 0000000..9c6f531
--- /dev/null
+++ b/src/cost_sharing/static/js/script.js
@@ -0,0 +1,150 @@
+const TOKEN_KEY = 'cost_sharing_token';
+const API_BASE = '';
+
+let currentToken = null;
+
+function init() {
+ // Check for token in localStorage
+ currentToken = localStorage.getItem(TOKEN_KEY);
+
+ // Check if we're handling an OAuth callback
+ const urlParams = new URLSearchParams(window.location.search);
+ const code = urlParams.get('code');
+
+ if (code) {
+ handleCallback(code);
+ } else if (currentToken) {
+ showLoggedInState();
+ fetchUserInfo();
+ } else {
+ showLoggedOutState();
+ }
+}
+
+function handleCallback(code) {
+ fetch(`${API_BASE}/auth/callback?code=${encodeURIComponent(code)}`)
+ .then(response => response.json())
+ .then(data => {
+ if (data.token) {
+ // Store token
+ localStorage.setItem(TOKEN_KEY, data.token);
+ currentToken = data.token;
+
+ // Clean URL (remove query params)
+ window.history.replaceState({}, document.title, window.location.pathname);
+
+ // Show logged in state
+ showLoggedInState();
+ fetchUserInfo();
+ } else {
+ showError(data.message || 'Authentication failed');
+ showLoggedOutState();
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ showError('Failed to complete authentication');
+ showLoggedOutState();
+ });
+}
+
+function login() {
+ fetch(`${API_BASE}/auth/login`)
+ .then(response => response.json())
+ .then(data => {
+ if (data.url) {
+ // Redirect to Google OAuth
+ window.location.href = data.url;
+ } else {
+ showError('Failed to get login URL');
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ showError('Failed to initiate login');
+ });
+}
+
+function fetchUserInfo() {
+ if (!currentToken) {
+ return;
+ }
+
+ fetch(`${API_BASE}/auth/me`, {
+ headers: {
+ 'Authorization': `Bearer ${currentToken}`
+ }
+ })
+ .then(response => {
+ if (!response.ok) {
+ if (response.status === 401) {
+ // Token invalid, clear it
+ logout();
+ throw new Error('Authentication failed');
+ }
+ return response.json().then(data => {
+ throw new Error(data.message || 'Failed to fetch user info');
+ });
+ }
+ return response.json();
+ })
+ .then(user => {
+ displayUserInfo(user);
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ showError(error.message || 'Failed to fetch user info');
+ });
+}
+
+function logout() {
+ localStorage.removeItem(TOKEN_KEY);
+ currentToken = null;
+ showLoggedOutState();
+}
+
+function displayUserInfo(user) {
+ document.getElementById('user-id').textContent = user.id;
+ document.getElementById('user-email').textContent = user.email;
+ document.getElementById('user-name').textContent = user.name;
+ document.getElementById('user-token').textContent = currentToken;
+}
+
+function showLoggedInState() {
+ document.getElementById('status').textContent = '✓ Logged In';
+ document.getElementById('status').className = 'status logged-in';
+ document.getElementById('login-section').classList.add('hidden');
+ document.getElementById('user-section').classList.remove('hidden');
+}
+
+function showLoggedOutState() {
+ document.getElementById('status').textContent = 'Not Logged In';
+ document.getElementById('status').className = 'status logged-out';
+ document.getElementById('login-section').classList.remove('hidden');
+ document.getElementById('user-section').classList.add('hidden');
+}
+
+function showError(message) {
+ let errorDiv = document.getElementById('error');
+ if (!errorDiv) {
+ errorDiv = document.createElement('div');
+ errorDiv.id = 'error';
+ errorDiv.className = 'error';
+ document.querySelector('.container').insertBefore(errorDiv, document.querySelector('.container').firstChild);
+ }
+ errorDiv.textContent = message;
+ errorDiv.classList.remove('hidden');
+
+ // Hide error after 5 seconds
+ setTimeout(() => {
+ errorDiv.classList.add('hidden');
+ }, 5000);
+}
+
+// Initialize when DOM is loaded
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+} else {
+ init();
+}
+
diff --git a/src/cost_sharing/templates/index.html b/src/cost_sharing/templates/index.html
new file mode 100644
index 0000000..6030909
--- /dev/null
+++ b/src/cost_sharing/templates/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+ Cost Sharing Demo
+
+
+
+
+
Cost Sharing Demo
+
Loading...
+
+
+
+
+
+
+
+
User Information
+
ID: -
+
Email: -
+
Name: -
+
+
+ Show Token (for debugging)
+ -
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/cost_sharing_mock.py b/tests/cost_sharing_mock.py
new file mode 100644
index 0000000..3100aa2
--- /dev/null
+++ b/tests/cost_sharing_mock.py
@@ -0,0 +1,112 @@
+"""Mock implementation of CostSharing for testing"""
+
+from cost_sharing.models import User
+
+
+class CostSharingMock:
+ """
+ Mock implementation of CostSharing for testing.
+
+ This mock allows you to configure behavior for testing different scenarios.
+ Use the configuration methods to set up the desired behavior before passing
+ the mock to create_app().
+ """
+
+ def __init__(self):
+ """Initialize the mock."""
+ # Configuration for get_or_create_user
+ self._get_or_create_result = None
+ self._get_or_create_exception = None
+
+ # Configuration for get_user_by_id
+ self._get_user_by_id_result = None
+ self._get_user_by_id_exception = None
+
+ def get_or_create_user_returns(self, user_id, email, name):
+ """
+ Configure get_or_create_user to return successfully.
+
+ Args:
+ user_id: User ID to return
+ email: Email to return
+ name: Name to return
+ """
+ self._get_or_create_result = User(id=user_id, email=email, name=name)
+ self._get_or_create_exception = None
+
+ def get_or_create_user_raises(self, exception):
+ """
+ Configure get_or_create_user to raise an exception.
+
+ Args:
+ exception: Exception instance to raise
+ """
+ self._get_or_create_result = None
+ self._get_or_create_exception = exception
+
+ def get_user_by_id_returns(self, user_id, email, name):
+ """
+ Configure get_user_by_id to return successfully.
+
+ Args:
+ user_id: User ID to return
+ email: Email to return
+ name: Name to return
+ """
+ self._get_user_by_id_result = User(id=user_id, email=email, name=name)
+ self._get_user_by_id_exception = None
+
+ def get_user_by_id_raises(self, exception):
+ """
+ Configure get_user_by_id to raise an exception.
+
+ Args:
+ exception: Exception instance to raise (UserNotFoundError)
+ """
+ self._get_user_by_id_result = None
+ self._get_user_by_id_exception = exception
+
+ def get_or_create_user(self, email, name): # pylint: disable=W0613
+ """
+ Get existing user or create new user (mock implementation).
+
+ Args:
+ email: User's email (ignored, uses configured behavior)
+ name: User's name (ignored, uses configured behavior)
+
+ Returns:
+ User object from configured result
+
+ Raises:
+ Exception: If configured to raise an exception
+ """
+ if self._get_or_create_exception is not None:
+ raise self._get_or_create_exception
+
+ if self._get_or_create_result is None:
+ # Default behavior if not configured
+ return User(id=1, email="default@example.com", name="Default User")
+
+ return self._get_or_create_result
+
+ def get_user_by_id(self, user_id): # pylint: disable=W0613
+ """
+ Get user by their ID (mock implementation).
+
+ Args:
+ user_id: User ID (ignored, uses configured behavior)
+
+ Returns:
+ User object from configured result
+
+ Raises:
+ UserNotFoundError: If configured to raise UserNotFoundError
+ """
+ if self._get_user_by_id_exception is not None:
+ raise self._get_user_by_id_exception
+
+ if self._get_user_by_id_result is None:
+ # Default behavior if not configured
+ return User(id=1, email="default@example.com", name="Default User")
+
+ return self._get_user_by_id_result
diff --git a/tests/oauth_handler_mock.py b/tests/oauth_handler_mock.py
new file mode 100644
index 0000000..cba9833
--- /dev/null
+++ b/tests/oauth_handler_mock.py
@@ -0,0 +1,129 @@
+"""Mock implementation of OAuthHandler for testing"""
+
+class OAuthHandlerMock:
+ """
+ Mock implementation of OAuthHandler for testing.
+
+ This mock allows you to configure behavior for testing different scenarios.
+ Use the configuration methods to set up the desired behavior before passing
+ the mock to create_app().
+ """
+
+ def __init__(self):
+ """Initialize the mock."""
+ # Configuration for exchange_code_for_user_info
+ self._exchange_code_result = None
+ self._exchange_code_exception = None
+
+ # Configuration for validate_jwt_token
+ self._validate_token_result = None
+ self._validate_token_exception = None
+
+ def exchange_code_returns(self, email, name):
+ """
+ Configure exchange_code_for_user_info to return successfully.
+
+ Args:
+ email: Email to return
+ name: Name to return
+ """
+ self._exchange_code_result = {"email": email, "name": name}
+ self._exchange_code_exception = None
+
+ def exchange_code_raises(self, exception):
+ """
+ Configure exchange_code_for_user_info to raise an exception.
+
+ Args:
+ exception: Exception instance to raise (OAuthCodeError or OAuthVerificationError)
+ """
+ self._exchange_code_result = None
+ self._exchange_code_exception = exception
+
+ def validate_token_returns(self, user_id):
+ """
+ Configure validate_jwt_token to return successfully.
+
+ Args:
+ user_id: User ID to return
+ """
+ self._validate_token_result = user_id
+ self._validate_token_exception = None
+
+ def validate_token_raises(self, exception):
+ """
+ Configure validate_jwt_token to raise an exception.
+
+ Args:
+ exception: Exception instance to raise (TokenExpiredError or TokenInvalidError)
+ """
+ self._validate_token_result = None
+ self._validate_token_exception = exception
+
+ def get_authorization_url(self):
+ """
+ Get Google OAuth authorization URL (mock implementation).
+
+ Returns:
+ Tuple of (authorization_url, state) with dummy values
+ """
+ return ("https://accounts.google.com/o/oauth2/auth?dummy=true", "dummy-state-123")
+
+ def exchange_code_for_user_info(self, oauth_code): # pylint: disable=W0613
+ """
+ Exchange OAuth authorization code for user information (mock implementation).
+
+ Args:
+ oauth_code: Authorization code (ignored, uses configured behavior)
+
+ Returns:
+ Dict with keys: email, name
+
+ Raises:
+ OAuthCodeError: If configured to raise OAuthCodeError
+ OAuthVerificationError: If configured to raise OAuthVerificationError
+ """
+ if self._exchange_code_exception is not None:
+ raise self._exchange_code_exception
+
+ if self._exchange_code_result is None:
+ # Default behavior if not configured
+ return {"email": "default@example.com", "name": "Default User"}
+
+ return self._exchange_code_result
+
+ def create_jwt_token(self, user_id, expiration_days=7): # pylint: disable=W0613
+ """
+ Create a JWT token for authenticated user (mock implementation).
+
+ Args:
+ user_id: User ID to encode in token
+ expiration_days: Number of days until token expires (ignored in mock)
+
+ Returns:
+ Dummy JWT token string
+ """
+ return f"dummy-jwt-token-for-user-{user_id}"
+
+ def validate_jwt_token(self, token): # pylint: disable=W0613
+ """
+ Validate JWT token and extract user ID (mock implementation).
+
+ Args:
+ token: JWT token string (ignored, uses configured behavior)
+
+ Returns:
+ user_id from configured result
+
+ Raises:
+ TokenExpiredError: If configured to raise TokenExpiredError
+ TokenInvalidError: If configured to raise TokenInvalidError
+ """
+ if self._validate_token_exception is not None:
+ raise self._validate_token_exception
+
+ if self._validate_token_result is None:
+ # Default behavior if not configured
+ return 1
+
+ return self._validate_token_result
diff --git a/tests/test_app.py b/tests/test_app.py
index fbd68f1..14d02a6 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,14 +1,213 @@
-
import pytest
+from oauth_handler_mock import OAuthHandlerMock
+from cost_sharing_mock import CostSharingMock
from cost_sharing.app import create_app
+from cost_sharing.oauth_handler import (
+ OAuthCodeError, OAuthVerificationError,
+ TokenExpiredError, TokenInvalidError
+)
+from cost_sharing.exceptions import UserNotFoundError
@pytest.fixture(name='client')
def create_client():
- app = create_app()
+ """Create Flask test client with mocked dependencies."""
+ # Create mocks for dependencies
+ oauth_handler = OAuthHandlerMock()
+ application = CostSharingMock()
+
+ app = create_app(oauth_handler, application)
+ return app.test_client()
+
+
+@pytest.fixture(name='oauth_handler')
+def create_oauth_handler():
+ """Create OAuth handler mock for test configuration."""
+ return OAuthHandlerMock()
+
+
+@pytest.fixture(name='application')
+def create_application():
+ """Create application mock for test configuration."""
+ return CostSharingMock()
+
+
+@pytest.fixture(name='configured_client')
+def create_configured_client(oauth_handler, application):
+ """Create Flask test client with configured mocks."""
+ app = create_app(oauth_handler, application)
return app.test_client()
+
def test_index(client):
+ """Test that index route returns the demo page HTML."""
response = client.get('/')
assert response.status_code == 200
- assert response.data.decode('utf-8') == "Hello, World!"
+ html = response.data.decode('utf-8')
+ assert 'Cost Sharing Demo' in html
+ assert 'style.css' in html
+ assert 'script.js' in html
+
+
+def test_auth_callback_success(configured_client, oauth_handler, application):
+ """Test successful OAuth callback."""
+ # Configure mocks
+ oauth_handler.exchange_code_returns("test@example.com", "Test User")
+ application.get_or_create_user_returns(1, "test@example.com", "Test User")
+
+ # Make request
+ response = configured_client.get('/auth/callback?code=test123')
+
+ # Verify response
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['token'] == "dummy-jwt-token-for-user-1"
+ assert data['user']['id'] == 1
+ assert data['user']['email'] == "test@example.com"
+ assert data['user']['name'] == "Test User"
+
+
+def test_auth_callback_missing_code(configured_client):
+ """Test OAuth callback with missing code parameter."""
+ response = configured_client.get('/auth/callback')
+
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['error'] == "Validation failed"
+ assert data['message'] == "code parameter is required"
+
+
+def test_auth_callback_invalid_code(configured_client, oauth_handler):
+ """Test OAuth callback with invalid authorization code."""
+ # Configure mocks
+ oauth_handler.exchange_code_raises(OAuthCodeError("Invalid code"))
+
+ # Make request
+ response = configured_client.get('/auth/callback?code=invalid')
+
+ # Verify response
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['error'] == "Validation failed"
+ assert data['message'] == "Invalid or expired authorization code"
+
+
+def test_auth_callback_verification_error(configured_client, oauth_handler):
+ """Test OAuth callback with verification error."""
+ # Configure mocks
+ oauth_handler.exchange_code_raises(OAuthVerificationError("Verification failed"))
+
+ # Make request
+ response = configured_client.get('/auth/callback?code=test123')
+
+ # Verify response
+ assert response.status_code == 401
+ data = response.get_json()
+ assert data['error'] == "Unauthorized"
+ assert data['message'] == "OAuth verification failed"
+
+
+def test_auth_me_success(configured_client, oauth_handler, application):
+ """Test successful /auth/me request."""
+ # Configure mocks
+ oauth_handler.validate_token_returns(1)
+ application.get_user_by_id_returns(1, "test@example.com", "Test User")
+
+ # Make request with Authorization header
+ response = configured_client.get(
+ '/auth/me',
+ headers={'Authorization': 'Bearer valid-token-123'}
+ )
+
+ # Verify response
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['id'] == 1
+ assert data['email'] == "test@example.com"
+ assert data['name'] == "Test User"
+
+
+def test_auth_me_missing_header(configured_client):
+ """Test /auth/me with missing Authorization header."""
+ response = configured_client.get('/auth/me')
+
+ assert response.status_code == 401
+ data = response.get_json()
+ assert data['error'] == "Unauthorized"
+ assert data['message'] == "Authentication required"
+
+
+def test_auth_me_invalid_header_format(configured_client):
+ """Test /auth/me with invalid Authorization header format."""
+ # Missing "Bearer " prefix
+ response = configured_client.get(
+ '/auth/me',
+ headers={'Authorization': 'invalid-token-123'}
+ )
+
+ assert response.status_code == 401
+ data = response.get_json()
+ assert data['error'] == "Unauthorized"
+ assert data['message'] == "Authentication required"
+
+
+def test_auth_me_expired_token(configured_client, oauth_handler):
+ """Test /auth/me with expired token."""
+ # Configure mock to raise TokenExpiredError
+ oauth_handler.validate_token_raises(TokenExpiredError("Token expired"))
+
+ response = configured_client.get(
+ '/auth/me',
+ headers={'Authorization': 'Bearer expired-token'}
+ )
+
+ assert response.status_code == 401
+ data = response.get_json()
+ assert data['error'] == "Unauthorized"
+ assert data['message'] == "Authentication required"
+
+
+def test_auth_me_invalid_token(configured_client, oauth_handler):
+ """Test /auth/me with invalid token."""
+ # Configure mock to raise TokenInvalidError
+ oauth_handler.validate_token_raises(TokenInvalidError("Invalid token"))
+
+ response = configured_client.get(
+ '/auth/me',
+ headers={'Authorization': 'Bearer invalid-token'}
+ )
+
+ assert response.status_code == 401
+ data = response.get_json()
+ assert data['error'] == "Unauthorized"
+ assert data['message'] == "Authentication required"
+
+
+def test_auth_me_user_not_found(configured_client, oauth_handler, application):
+ """Test /auth/me when user doesn't exist."""
+ # Configure mocks
+ oauth_handler.validate_token_returns(999) # Valid token but user doesn't exist
+ application.get_user_by_id_raises(UserNotFoundError("User not found"))
+
+ response = configured_client.get(
+ '/auth/me',
+ headers={'Authorization': 'Bearer valid-token'}
+ )
+
+ assert response.status_code == 404
+ data = response.get_json()
+ assert data['error'] == "Resource not found"
+ assert data['message'] == "User not found"
+
+
+def test_auth_login_success(configured_client):
+ """Test /auth/login returns authorization URL."""
+ # OAuthHandlerMock.get_authorization_url() returns dummy values
+ response = configured_client.get('/auth/login')
+
+ assert response.status_code == 200
+ data = response.get_json()
+ assert 'url' in data
+ assert 'state' in data
+ assert data['url'] == "https://accounts.google.com/o/oauth2/auth?dummy=true"
+ assert data['state'] == "dummy-state-123"
diff --git a/tests/test_oauth_handler.py b/tests/test_oauth_handler.py
index a666995..70e0c26 100644
--- a/tests/test_oauth_handler.py
+++ b/tests/test_oauth_handler.py
@@ -121,4 +121,4 @@ def test_redirect_uri_computed_from_base_url():
jwt_secret="test-jwt-secret"
)
- assert test_handler.redirect_uri == "https://example.com/auth/callback"
+ assert test_handler.redirect_uri == "https://example.com/"