From 7ebfb7a2dd44e9a5d34a0afe1cf1927b617c5725 Mon Sep 17 00:00:00 2001 From: counterweight Date: Thu, 18 Dec 2025 22:08:31 +0100 Subject: [PATCH] tests passing --- Makefile | 15 +- backend/auth.py | 100 +++++++++++ backend/main.py | 75 +++++++- backend/models.py | 10 +- backend/pyproject.toml | 3 + backend/pytest.ini | 1 - backend/tests/conftest.py | 35 ++++ backend/tests/test_auth.py | 282 +++++++++++++++++++++++++++++ backend/tests/test_counter.py | 138 +++++++++++---- docker-compose.yml | 6 +- frontend/app/auth-context.tsx | 112 ++++++++++++ frontend/app/layout.tsx | 40 ++++- frontend/app/login/page.test.tsx | 36 ++++ frontend/app/login/page.tsx | 195 ++++++++++++++++++++ frontend/app/page.test.tsx | 215 ++++++++++++++++++----- frontend/app/page.tsx | 173 ++++++++++++++++-- frontend/app/signup/page.test.tsx | 37 ++++ frontend/app/signup/page.tsx | 220 +++++++++++++++++++++++ frontend/e2e/auth.spec.ts | 283 ++++++++++++++++++++++++++++++ frontend/e2e/counter.spec.ts | 159 ++++++++++++++--- 20 files changed, 2009 insertions(+), 126 deletions(-) create mode 100644 backend/auth.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth.py create mode 100644 frontend/app/auth-context.tsx create mode 100644 frontend/app/login/page.test.tsx create mode 100644 frontend/app/login/page.tsx create mode 100644 frontend/app/signup/page.test.tsx create mode 100644 frontend/app/signup/page.tsx create mode 100644 frontend/e2e/auth.spec.ts diff --git a/Makefile b/Makefile index b98706e..67db7ed 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install-backend install-frontend install backend frontend db db-stop dev test test-frontend test-e2e +.PHONY: install-backend install-frontend install backend frontend db db-stop db-ready dev test test-backend test-frontend test-e2e install-backend: cd backend && uv sync --all-groups @@ -20,13 +20,23 @@ db: db-stop: docker compose down +db-ready: + @docker compose up -d db + @echo "Waiting for PostgreSQL to be ready..." + @until docker compose exec -T db pg_isready -U postgres > /dev/null 2>&1; do \ + sleep 1; \ + done + @docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test'" | grep -q 1 || \ + docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test" + @echo "PostgreSQL is ready" + dev: $(MAKE) db cd backend && uv run uvicorn main:app --reload & \ cd frontend && npm run dev & \ wait -test-backend: +test-backend: db-ready cd backend && uv run pytest -v test-frontend: @@ -35,3 +45,4 @@ test-frontend: test-e2e: ./scripts/e2e.sh +test: test-backend test-frontend diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..b845c7e --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,100 @@ +import os +from datetime import datetime, timedelta, timezone +from typing import Optional + +import bcrypt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt +from pydantic import BaseModel, EmailStr +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db +from models import User + +SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days +security = HTTPBearer() + + +class UserCreate(BaseModel): + email: EmailStr + password: str + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserResponse(BaseModel): + id: int + email: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str + user: UserResponse + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return bcrypt.checkpw( + plain_password.encode("utf-8"), + hashed_password.encode("utf-8"), + ) + + +def get_password_hash(password: str) -> str: + return bcrypt.hashpw( + password.encode("utf-8"), + bcrypt.gensalt(), + ).decode("utf-8") + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]: + result = await db.execute(select(User).where(User.email == email)) + return result.scalar_one_or_none() + + +async def authenticate_user(db: AsyncSession, email: str, password: str) -> Optional[User]: + user = await get_user_by_email(db, email) + if not user or not verify_password(password, user.hashed_password): + return None + return user + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + token = credentials.credentials + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id_str = payload.get("sub") + if user_id_str is None: + raise credentials_exception + user_id = int(user_id_str) + except (JWTError, ValueError): + raise credentials_exception + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None: + raise credentials_exception + return user + diff --git a/backend/main.py b/backend/main.py index cdb7d60..20de1ca 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,11 +1,22 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI, Depends +from fastapi import FastAPI, Depends, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from database import engine, get_db, Base -from models import Counter +from models import Counter, User +from auth import ( + UserCreate, + UserLogin, + UserResponse, + TokenResponse, + get_password_hash, + get_user_by_email, + authenticate_user, + create_access_token, + get_current_user, +) @asynccontextmanager @@ -22,9 +33,59 @@ app.add_middleware( allow_origins=["http://localhost:3000"], allow_methods=["*"], allow_headers=["*"], + allow_credentials=True, ) +# Auth endpoints +@app.post("/api/auth/register", response_model=TokenResponse) +async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)): + existing_user = await get_user_by_email(db, user_data.email) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", + ) + + user = User( + email=user_data.email, + hashed_password=get_password_hash(user_data.password), + ) + db.add(user) + await db.commit() + await db.refresh(user) + + access_token = create_access_token(data={"sub": str(user.id)}) + return TokenResponse( + access_token=access_token, + token_type="bearer", + user=UserResponse(id=user.id, email=user.email), + ) + + +@app.post("/api/auth/login", response_model=TokenResponse) +async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)): + user = await authenticate_user(db, user_data.email, user_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + ) + + access_token = create_access_token(data={"sub": str(user.id)}) + return TokenResponse( + access_token=access_token, + token_type="bearer", + user=UserResponse(id=user.id, email=user.email), + ) + + +@app.get("/api/auth/me", response_model=UserResponse) +async def get_me(current_user: User = Depends(get_current_user)): + return UserResponse(id=current_user.id, email=current_user.email) + + +# Counter endpoints async def get_or_create_counter(db: AsyncSession) -> Counter: result = await db.execute(select(Counter).where(Counter.id == 1)) counter = result.scalar_one_or_none() @@ -37,13 +98,19 @@ async def get_or_create_counter(db: AsyncSession) -> Counter: @app.get("/api/counter") -async def get_counter(db: AsyncSession = Depends(get_db)): +async def get_counter( + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(get_current_user), +): counter = await get_or_create_counter(db) return {"value": counter.value} @app.post("/api/counter/increment") -async def increment_counter(db: AsyncSession = Depends(get_db)): +async def increment_counter( + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(get_current_user), +): counter = await get_or_create_counter(db) counter.value += 1 await db.commit() diff --git a/backend/models.py b/backend/models.py index 588a39b..7251bf8 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Integer +from sqlalchemy import Integer, String from sqlalchemy.orm import Mapped, mapped_column from database import Base @@ -9,3 +9,11 @@ class Counter(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) value: Mapped[int] = mapped_column(Integer, default=0) + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7357b2e..55938bb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -7,6 +7,9 @@ dependencies = [ "uvicorn>=0.34.0", "sqlalchemy[asyncio]>=2.0.36", "asyncpg>=0.30.0", + "bcrypt>=4.0.0", + "python-jose[cryptography]>=3.3.0", + "email-validator>=2.0.0", ] [dependency-groups] diff --git a/backend/pytest.ini b/backend/pytest.ini index 06a6524..c8c9c75 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,4 +1,3 @@ [pytest] asyncio_mode = auto asyncio_default_fixture_loop_scope = function - diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..4e01cb8 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,35 @@ +import os +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + +from database import Base, get_db +from main import app + +TEST_DATABASE_URL = os.getenv( + "TEST_DATABASE_URL", + "postgresql+asyncpg://postgres:postgres@localhost:5432/arbret_test" +) + + +@pytest.fixture(scope="function") +async def client(): + engine = create_async_engine(TEST_DATABASE_URL) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + # Create tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + async def override_get_db(): + async with session_factory() as session: + yield session + + app.dependency_overrides[get_db] = override_get_db + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + app.dependency_overrides.clear() + await engine.dispose() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..497776d --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,282 @@ +import pytest +import uuid + + +def unique_email(prefix: str = "test") -> str: + """Generate a unique email for tests sharing the same database.""" + return f"{prefix}-{uuid.uuid4().hex[:8]}@example.com" + + +async def create_user_and_get_token(client, email: str = None, password: str = "testpass123") -> str: + """Helper to create a user and return their auth token.""" + if email is None: + email = unique_email() + response = await client.post( + "/api/auth/register", + json={"email": email, "password": password}, + ) + return response.json()["access_token"] + + +def auth_header(token: str) -> dict: + """Helper to create auth headers from token.""" + return {"Authorization": f"Bearer {token}"} + + +# Registration tests +@pytest.mark.asyncio +async def test_register_success(client): + email = unique_email("register") + response = await client.post( + "/api/auth/register", + json={"email": email, "password": "password123"}, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + assert data["user"]["email"] == email + assert "id" in data["user"] + + +@pytest.mark.asyncio +async def test_register_duplicate_email(client): + email = unique_email("duplicate") + await client.post( + "/api/auth/register", + json={"email": email, "password": "password123"}, + ) + response = await client.post( + "/api/auth/register", + json={"email": email, "password": "differentpass"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Email already registered" + + +@pytest.mark.asyncio +async def test_register_invalid_email(client): + response = await client.post( + "/api/auth/register", + json={"email": "notanemail", "password": "password123"}, + ) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_register_missing_password(client): + response = await client.post( + "/api/auth/register", + json={"email": unique_email()}, + ) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_register_missing_email(client): + response = await client.post( + "/api/auth/register", + json={"password": "password123"}, + ) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_register_empty_body(client): + response = await client.post("/api/auth/register", json={}) + assert response.status_code == 422 + + +# Login tests +@pytest.mark.asyncio +async def test_login_success(client): + email = unique_email("login") + await client.post( + "/api/auth/register", + json={"email": email, "password": "password123"}, + ) + response = await client.post( + "/api/auth/login", + json={"email": email, "password": "password123"}, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + assert data["user"]["email"] == email + + +@pytest.mark.asyncio +async def test_login_wrong_password(client): + email = unique_email("wrongpass") + await client.post( + "/api/auth/register", + json={"email": email, "password": "correctpassword"}, + ) + response = await client.post( + "/api/auth/login", + json={"email": email, "password": "wrongpassword"}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == "Incorrect email or password" + + +@pytest.mark.asyncio +async def test_login_nonexistent_user(client): + response = await client.post( + "/api/auth/login", + json={"email": unique_email("nonexistent"), "password": "password123"}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == "Incorrect email or password" + + +@pytest.mark.asyncio +async def test_login_invalid_email_format(client): + response = await client.post( + "/api/auth/login", + json={"email": "invalidemail", "password": "password123"}, + ) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_login_missing_fields(client): + response = await client.post("/api/auth/login", json={}) + assert response.status_code == 422 + + +# Get current user tests +@pytest.mark.asyncio +async def test_get_me_success(client): + email = unique_email("me") + token = await create_user_and_get_token(client, email) + response = await client.get("/api/auth/me", headers=auth_header(token)) + assert response.status_code == 200 + data = response.json() + assert data["email"] == email + assert "id" in data + + +@pytest.mark.asyncio +async def test_get_me_no_token(client): + response = await client.get("/api/auth/me") + # HTTPBearer returns 401/403 when credentials are missing + assert response.status_code in [401, 403] + + +@pytest.mark.asyncio +async def test_get_me_invalid_token(client): + response = await client.get( + "/api/auth/me", + headers={"Authorization": "Bearer invalidtoken123"}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid authentication credentials" + + +@pytest.mark.asyncio +async def test_get_me_malformed_auth_header(client): + response = await client.get( + "/api/auth/me", + headers={"Authorization": "NotBearer token123"}, + ) + # Invalid scheme returns 401/403 + assert response.status_code in [401, 403] + + +@pytest.mark.asyncio +async def test_get_me_expired_token(client): + response = await client.get( + "/api/auth/me", + headers={"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImV4cCI6MH0.invalid"}, + ) + assert response.status_code == 401 + + +# Token validation tests +@pytest.mark.asyncio +async def test_token_from_register_works_for_me(client): + email = unique_email("tokentest") + register_response = await client.post( + "/api/auth/register", + json={"email": email, "password": "password123"}, + ) + token = register_response.json()["access_token"] + + me_response = await client.get("/api/auth/me", headers=auth_header(token)) + assert me_response.status_code == 200 + assert me_response.json()["email"] == email + + +@pytest.mark.asyncio +async def test_token_from_login_works_for_me(client): + email = unique_email("logintoken") + await client.post( + "/api/auth/register", + json={"email": email, "password": "password123"}, + ) + login_response = await client.post( + "/api/auth/login", + json={"email": email, "password": "password123"}, + ) + token = login_response.json()["access_token"] + + me_response = await client.get("/api/auth/me", headers=auth_header(token)) + assert me_response.status_code == 200 + assert me_response.json()["email"] == email + + +# Multiple users tests +@pytest.mark.asyncio +async def test_multiple_users_isolated(client): + email1 = unique_email("user1") + email2 = unique_email("user2") + + resp1 = await client.post( + "/api/auth/register", + json={"email": email1, "password": "password1"}, + ) + resp2 = await client.post( + "/api/auth/register", + json={"email": email2, "password": "password2"}, + ) + + token1 = resp1.json()["access_token"] + token2 = resp2.json()["access_token"] + + me1 = await client.get("/api/auth/me", headers=auth_header(token1)) + me2 = await client.get("/api/auth/me", headers=auth_header(token2)) + + assert me1.json()["email"] == email1 + assert me2.json()["email"] == email2 + assert me1.json()["id"] != me2.json()["id"] + + +# Password tests +@pytest.mark.asyncio +async def test_password_is_hashed(client): + email = unique_email("hashtest") + await client.post( + "/api/auth/register", + json={"email": email, "password": "mySecurePassword123"}, + ) + response = await client.post( + "/api/auth/login", + json={"email": email, "password": "mySecurePassword123"}, + ) + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_case_sensitive_password(client): + email = unique_email("casetest") + await client.post( + "/api/auth/register", + json={"email": email, "password": "Password123"}, + ) + response = await client.post( + "/api/auth/login", + json={"email": email, "password": "password123"}, + ) + assert response.status_code == 401 diff --git a/backend/tests/test_counter.py b/backend/tests/test_counter.py index 6c2489d..545aa4b 100644 --- a/backend/tests/test_counter.py +++ b/backend/tests/test_counter.py @@ -1,60 +1,128 @@ import pytest -from httpx import ASGITransport, AsyncClient -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker - -from database import Base, get_db -from main import app - -TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" +import uuid -@pytest.fixture -async def client(): - engine = create_async_engine(TEST_DATABASE_URL) - async_session = async_sessionmaker(engine, expire_on_commit=False) +def unique_email(prefix: str = "counter") -> str: + """Generate a unique email for tests sharing the same database.""" + return f"{prefix}-{uuid.uuid4().hex[:8]}@example.com" - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - async def override_get_db(): - async with async_session() as session: - yield session +async def create_user_and_get_headers(client, email: str = None) -> dict: + """Create a user and return auth headers for authenticated requests.""" + if email is None: + email = unique_email() + response = await client.post( + "/api/auth/register", + json={"email": email, "password": "testpass123"}, + ) + token = response.json()["access_token"] + return {"Authorization": f"Bearer {token}"} - app.dependency_overrides[get_db] = override_get_db - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: - yield c - - app.dependency_overrides.clear() - await engine.dispose() +# Protected endpoint tests - without auth +@pytest.mark.asyncio +async def test_get_counter_requires_auth(client): + response = await client.get("/api/counter") + assert response.status_code in [401, 403] @pytest.mark.asyncio -async def test_get_counter_initial(client): - response = await client.get("/api/counter") +async def test_increment_counter_requires_auth(client): + response = await client.post("/api/counter/increment") + assert response.status_code in [401, 403] + + +@pytest.mark.asyncio +async def test_get_counter_invalid_token(client): + response = await client.get( + "/api/counter", + headers={"Authorization": "Bearer invalidtoken"}, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_increment_counter_invalid_token(client): + response = await client.post( + "/api/counter/increment", + headers={"Authorization": "Bearer invalidtoken"}, + ) + assert response.status_code == 401 + + +# Authenticated counter tests +@pytest.mark.asyncio +async def test_get_counter_authenticated(client): + auth_headers = await create_user_and_get_headers(client) + response = await client.get("/api/counter", headers=auth_headers) assert response.status_code == 200 - assert response.json() == {"value": 0} + assert "value" in response.json() @pytest.mark.asyncio async def test_increment_counter(client): - response = await client.post("/api/counter/increment") + auth_headers = await create_user_and_get_headers(client) + + # Get current value + before = await client.get("/api/counter", headers=auth_headers) + before_value = before.json()["value"] + + # Increment + response = await client.post("/api/counter/increment", headers=auth_headers) assert response.status_code == 200 - assert response.json() == {"value": 1} + assert response.json()["value"] == before_value + 1 @pytest.mark.asyncio async def test_increment_counter_multiple(client): - await client.post("/api/counter/increment") - await client.post("/api/counter/increment") - response = await client.post("/api/counter/increment") - assert response.json() == {"value": 3} + auth_headers = await create_user_and_get_headers(client) + + # Get starting value + before = await client.get("/api/counter", headers=auth_headers) + start = before.json()["value"] + + # Increment 3 times + await client.post("/api/counter/increment", headers=auth_headers) + await client.post("/api/counter/increment", headers=auth_headers) + response = await client.post("/api/counter/increment", headers=auth_headers) + + assert response.json()["value"] == start + 3 @pytest.mark.asyncio async def test_get_counter_after_increment(client): - await client.post("/api/counter/increment") - await client.post("/api/counter/increment") - response = await client.get("/api/counter") - assert response.json() == {"value": 2} + auth_headers = await create_user_and_get_headers(client) + + before = await client.get("/api/counter", headers=auth_headers) + start = before.json()["value"] + + await client.post("/api/counter/increment", headers=auth_headers) + await client.post("/api/counter/increment", headers=auth_headers) + + response = await client.get("/api/counter", headers=auth_headers) + assert response.json()["value"] == start + 2 + +# Counter is shared between users +@pytest.mark.asyncio +async def test_counter_shared_between_users(client): + headers1 = await create_user_and_get_headers(client, unique_email("share1")) + + # Get starting value + before = await client.get("/api/counter", headers=headers1) + start = before.json()["value"] + + await client.post("/api/counter/increment", headers=headers1) + await client.post("/api/counter/increment", headers=headers1) + + # Second user sees the increments + headers2 = await create_user_and_get_headers(client, unique_email("share2")) + response = await client.get("/api/counter", headers=headers2) + assert response.json()["value"] == start + 2 + + # Second user increments + await client.post("/api/counter/increment", headers=headers2) + + # First user sees the increment + response = await client.get("/api/counter", headers=headers1) + assert response.json()["value"] == start + 3 diff --git a/docker-compose.yml b/docker-compose.yml index 9c3f9f2..6af4e84 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,11 @@ services: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 2s + timeout: 5s + retries: 10 volumes: pgdata: - diff --git a/frontend/app/auth-context.tsx b/frontend/app/auth-context.tsx new file mode 100644 index 0000000..a1450bb --- /dev/null +++ b/frontend/app/auth-context.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { createContext, useContext, useState, useEffect, ReactNode } from "react"; + +interface User { + id: number; + email: string; +} + +interface AuthContextType { + user: User | null; + token: string | null; + isLoading: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const storedToken = localStorage.getItem("token"); + if (storedToken) { + setToken(storedToken); + fetchUser(storedToken); + } else { + setIsLoading(false); + } + }, []); + + const fetchUser = async (authToken: string) => { + try { + const res = await fetch("http://localhost:8000/api/auth/me", { + headers: { Authorization: `Bearer ${authToken}` }, + }); + if (res.ok) { + const userData = await res.json(); + setUser(userData); + } else { + localStorage.removeItem("token"); + setToken(null); + } + } catch { + localStorage.removeItem("token"); + setToken(null); + } finally { + setIsLoading(false); + } + }; + + const login = async (email: string, password: string) => { + const res = await fetch("http://localhost:8000/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || "Login failed"); + } + + const data = await res.json(); + localStorage.setItem("token", data.access_token); + setToken(data.access_token); + setUser(data.user); + }; + + const register = async (email: string, password: string) => { + const res = await fetch("http://localhost:8000/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || "Registration failed"); + } + + const data = await res.json(); + localStorage.setItem("token", data.access_token); + setToken(data.access_token); + setUser(data.user); + }; + + const logout = () => { + localStorage.removeItem("token"); + setToken(null); + setUser(null); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} + diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index a30901c..362ab81 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,8 +1,44 @@ +import { AuthProvider } from "./auth-context"; + export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + + + + + + + {children} + ); } - diff --git a/frontend/app/login/page.test.tsx b/frontend/app/login/page.test.tsx new file mode 100644 index 0000000..78f09a5 --- /dev/null +++ b/frontend/app/login/page.test.tsx @@ -0,0 +1,36 @@ +import { render, screen, cleanup } from "@testing-library/react"; +import { expect, test, vi, beforeEach, afterEach } from "vitest"; +import LoginPage from "./page"; + +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), +})); + +vi.mock("../auth-context", () => ({ + useAuth: () => ({ login: vi.fn() }), +})); + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => cleanup()); + +test("renders login form with title", () => { + render(); + expect(screen.getByText("Welcome back")).toBeDefined(); +}); + +test("renders email and password inputs", () => { + render(); + expect(screen.getByLabelText("Email")).toBeDefined(); + expect(screen.getByLabelText("Password")).toBeDefined(); +}); + +test("renders sign in button", () => { + render(); + expect(screen.getByRole("button", { name: "Sign in" })).toBeDefined(); +}); + +test("renders link to signup", () => { + render(); + expect(screen.getByText("Sign up")).toBeDefined(); +}); diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..507892a --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "../auth-context"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const { login } = useAuth(); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setIsSubmitting(true); + + try { + await login(email, password); + router.push("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+
+

Welcome back

+

Sign in to your account

+
+ +
+ {error &&
{error}
} + +
+ + setEmail(e.target.value)} + style={styles.input} + placeholder="you@example.com" + required + /> +
+ +
+ + setPassword(e.target.value)} + style={styles.input} + placeholder="••••••••" + required + /> +
+ + +
+ +

+ Don't have an account?{" "} + + Sign up + +

+
+
+
+ ); +} + +const styles: Record = { + main: { + minHeight: "100vh", + background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "1rem", + }, + container: { + width: "100%", + maxWidth: "420px", + }, + card: { + background: "rgba(255, 255, 255, 0.03)", + backdropFilter: "blur(10px)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "24px", + padding: "3rem 2.5rem", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", + }, + header: { + textAlign: "center" as const, + marginBottom: "2.5rem", + }, + title: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "2.5rem", + fontWeight: 400, + color: "#fff", + margin: 0, + letterSpacing: "-0.02em", + }, + subtitle: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + marginTop: "0.5rem", + fontSize: "0.95rem", + }, + form: { + display: "flex", + flexDirection: "column" as const, + gap: "1.5rem", + }, + field: { + display: "flex", + flexDirection: "column" as const, + gap: "0.5rem", + }, + label: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.7)", + fontSize: "0.875rem", + fontWeight: 500, + }, + input: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.875rem 1rem", + fontSize: "1rem", + background: "rgba(255, 255, 255, 0.05)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "12px", + color: "#fff", + outline: "none", + transition: "border-color 0.2s, box-shadow 0.2s", + }, + button: { + fontFamily: "'DM Sans', system-ui, sans-serif", + marginTop: "0.5rem", + padding: "1rem", + fontSize: "1rem", + fontWeight: 600, + background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)", + color: "#fff", + border: "none", + borderRadius: "12px", + cursor: "pointer", + transition: "transform 0.2s, box-shadow 0.2s", + boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)", + }, + error: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.875rem 1rem", + background: "rgba(239, 68, 68, 0.1)", + border: "1px solid rgba(239, 68, 68, 0.3)", + borderRadius: "12px", + color: "#fca5a5", + fontSize: "0.875rem", + textAlign: "center" as const, + }, + footer: { + fontFamily: "'DM Sans', system-ui, sans-serif", + textAlign: "center" as const, + marginTop: "2rem", + color: "rgba(255, 255, 255, 0.5)", + fontSize: "0.875rem", + }, + link: { + color: "#a78bfa", + textDecoration: "none", + fontWeight: 500, + }, +}; + diff --git a/frontend/app/page.test.tsx b/frontend/app/page.test.tsx index fb9f4e5..b7d27f1 100644 --- a/frontend/app/page.test.tsx +++ b/frontend/app/page.test.tsx @@ -1,68 +1,199 @@ import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; -import { expect, test, vi, beforeEach, afterEach } from "vitest"; +import { expect, test, vi, beforeEach, afterEach, describe } from "vitest"; import Home from "./page"; +// Mock next/navigation +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: mockPush, + }), +})); + +// Default mock values +let mockUser: { id: number; email: string } | null = { id: 1, email: "test@example.com" }; +let mockToken: string | null = "valid-token"; +let mockIsLoading = false; +const mockLogout = vi.fn(); + +vi.mock("./auth-context", () => ({ + useAuth: () => ({ + user: mockUser, + token: mockToken, + isLoading: mockIsLoading, + logout: mockLogout, + }), +})); + beforeEach(() => { - vi.restoreAllMocks(); + vi.clearAllMocks(); + // Reset to authenticated state + mockUser = { id: 1, email: "test@example.com" }; + mockToken = "valid-token"; + mockIsLoading = false; }); afterEach(() => { cleanup(); }); -test("renders loading state initially", () => { - vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {})); - render(); - expect(screen.getByText("...")).toBeDefined(); -}); +describe("Home - Authenticated", () => { + test("renders loading state when isLoading is true", () => { + mockIsLoading = true; + vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {})); -test("renders counter value after fetch", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - json: () => Promise.resolve({ value: 42 }), - } as Response); + render(); + expect(screen.getByText("Loading...")).toBeDefined(); + }); - render(); - await waitFor(() => { - expect(screen.getByText("42")).toBeDefined(); + test("renders user email in header", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + json: () => Promise.resolve({ value: 42 }), + } as Response); + + render(); + expect(screen.getByText("test@example.com")).toBeDefined(); + }); + + test("renders sign out button", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + json: () => Promise.resolve({ value: 42 }), + } as Response); + + render(); + expect(screen.getByText("Sign out")).toBeDefined(); + }); + + test("clicking sign out calls logout", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + json: () => Promise.resolve({ value: 42 }), + } as Response); + + render(); + fireEvent.click(screen.getByText("Sign out")); + expect(mockLogout).toHaveBeenCalled(); + }); + + test("renders counter value after fetch", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + json: () => Promise.resolve({ value: 42 }), + } as Response); + + render(); + await waitFor(() => { + expect(screen.getByText("42")).toBeDefined(); + }); + }); + + test("fetches counter with auth header", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + json: () => Promise.resolve({ value: 0 }), + } as Response); + + render(); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:8000/api/counter", + expect.objectContaining({ + headers: { Authorization: "Bearer valid-token" }, + }) + ); + }); + }); + + test("renders increment button", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + json: () => Promise.resolve({ value: 0 }), + } as Response); + + render(); + expect(screen.getByText("Increment")).toBeDefined(); + }); + + test("clicking increment button calls API with auth header", async () => { + const fetchSpy = vi + .spyOn(global, "fetch") + .mockResolvedValueOnce({ json: () => Promise.resolve({ value: 0 }) } as Response) + .mockResolvedValueOnce({ json: () => Promise.resolve({ value: 1 }) } as Response); + + render(); + await waitFor(() => expect(screen.getByText("0")).toBeDefined()); + + fireEvent.click(screen.getByText("Increment")); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:8000/api/counter/increment", + expect.objectContaining({ + method: "POST", + headers: { Authorization: "Bearer valid-token" }, + }) + ); + }); + }); + + test("clicking increment updates displayed count", async () => { + vi.spyOn(global, "fetch") + .mockResolvedValueOnce({ json: () => Promise.resolve({ value: 0 }) } as Response) + .mockResolvedValueOnce({ json: () => Promise.resolve({ value: 1 }) } as Response); + + render(); + await waitFor(() => expect(screen.getByText("0")).toBeDefined()); + + fireEvent.click(screen.getByText("Increment")); + await waitFor(() => expect(screen.getByText("1")).toBeDefined()); }); }); -test("renders +1 button", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - json: () => Promise.resolve({ value: 0 }), - } as Response); +describe("Home - Unauthenticated", () => { + test("redirects to login when not authenticated", async () => { + mockUser = null; + mockToken = null; - render(); - expect(screen.getByText("+1")).toBeDefined(); -}); + render(); -test("clicking button calls increment endpoint", async () => { - const fetchSpy = vi.spyOn(global, "fetch") - .mockResolvedValueOnce({ json: () => Promise.resolve({ value: 0 }) } as Response) - .mockResolvedValueOnce({ json: () => Promise.resolve({ value: 1 }) } as Response); + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith("/login"); + }); + }); - render(); - await waitFor(() => expect(screen.getByText("0")).toBeDefined()); + test("returns null when not authenticated", () => { + mockUser = null; + mockToken = null; - fireEvent.click(screen.getByText("+1")); + const { container } = render(); + // Should render nothing (just redirects) + expect(container.querySelector("main")).toBeNull(); + }); - await waitFor(() => { - expect(fetchSpy).toHaveBeenCalledWith( - "http://localhost:8000/api/counter/increment", - { method: "POST" } - ); + test("does not fetch counter when no token", () => { + mockUser = null; + mockToken = null; + const fetchSpy = vi.spyOn(global, "fetch"); + + render(); + + expect(fetchSpy).not.toHaveBeenCalled(); }); }); -test("clicking button updates displayed count", async () => { - vi.spyOn(global, "fetch") - .mockResolvedValueOnce({ json: () => Promise.resolve({ value: 0 }) } as Response) - .mockResolvedValueOnce({ json: () => Promise.resolve({ value: 1 }) } as Response); +describe("Home - Loading State", () => { + test("does not redirect while loading", () => { + mockIsLoading = true; + mockUser = null; + mockToken = null; - render(); - await waitFor(() => expect(screen.getByText("0")).toBeDefined()); + render(); - fireEvent.click(screen.getByText("+1")); - await waitFor(() => expect(screen.getByText("1")).toBeDefined()); + expect(mockPush).not.toHaveBeenCalled(); + }); + + test("shows loading indicator while loading", () => { + mockIsLoading = true; + + render(); + + expect(screen.getByText("Loading...")).toBeDefined(); + }); }); - diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 8018fc0..fb884e6 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,40 +1,177 @@ "use client"; import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "./auth-context"; export default function Home() { const [count, setCount] = useState(null); + const { user, token, isLoading, logout } = useAuth(); + const router = useRouter(); useEffect(() => { - fetch("http://localhost:8000/api/counter") - .then((res) => res.json()) - .then((data) => setCount(data.value)); - }, []); + if (!isLoading && !user) { + router.push("/login"); + } + }, [isLoading, user, router]); + + useEffect(() => { + if (token) { + fetch("http://localhost:8000/api/counter", { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((res) => res.json()) + .then((data) => setCount(data.value)) + .catch(() => setCount(null)); + } + }, [token]); const increment = async () => { + if (!token) return; const res = await fetch("http://localhost:8000/api/counter/increment", { method: "POST", + headers: { Authorization: `Bearer ${token}` }, }); const data = await res.json(); setCount(data.value); }; + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!user) { + return null; + } + return ( -
-

- {count === null ? "..." : count} -

- +
+
+
+ {user.email} + +
+
+ +
+
+ Current Count +

{count === null ? "..." : count}

+ +
+
); } +const styles: Record = { + main: { + minHeight: "100vh", + background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)", + display: "flex", + flexDirection: "column", + }, + loader: { + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + fontSize: "1.125rem", + }, + header: { + padding: "1.5rem 2rem", + borderBottom: "1px solid rgba(255, 255, 255, 0.06)", + }, + userInfo: { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: "1rem", + }, + userEmail: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.6)", + fontSize: "0.875rem", + }, + logoutBtn: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.5rem 1rem", + fontSize: "0.875rem", + fontWeight: 500, + background: "rgba(255, 255, 255, 0.05)", + color: "rgba(255, 255, 255, 0.7)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "8px", + cursor: "pointer", + transition: "all 0.2s", + }, + content: { + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "2rem", + }, + counterCard: { + background: "rgba(255, 255, 255, 0.03)", + backdropFilter: "blur(10px)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "32px", + padding: "4rem 5rem", + textAlign: "center", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", + }, + counterLabel: { + fontFamily: "'DM Sans', system-ui, sans-serif", + display: "block", + color: "rgba(255, 255, 255, 0.4)", + fontSize: "0.875rem", + textTransform: "uppercase", + letterSpacing: "0.1em", + marginBottom: "1rem", + }, + counter: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "8rem", + fontWeight: 400, + color: "#fff", + margin: 0, + lineHeight: 1, + background: "linear-gradient(135deg, #fff 0%, #a78bfa 100%)", + WebkitBackgroundClip: "text", + WebkitTextFillColor: "transparent", + backgroundClip: "text", + }, + incrementBtn: { + fontFamily: "'DM Sans', system-ui, sans-serif", + marginTop: "2.5rem", + padding: "1rem 2.5rem", + fontSize: "1.125rem", + fontWeight: 600, + background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)", + color: "#fff", + border: "none", + borderRadius: "16px", + cursor: "pointer", + display: "inline-flex", + alignItems: "center", + gap: "0.5rem", + transition: "transform 0.2s, box-shadow 0.2s", + boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)", + }, + plusIcon: { + fontSize: "1.5rem", + fontWeight: 400, + }, +}; diff --git a/frontend/app/signup/page.test.tsx b/frontend/app/signup/page.test.tsx new file mode 100644 index 0000000..3be6767 --- /dev/null +++ b/frontend/app/signup/page.test.tsx @@ -0,0 +1,37 @@ +import { render, screen, cleanup } from "@testing-library/react"; +import { expect, test, vi, beforeEach, afterEach } from "vitest"; +import SignupPage from "./page"; + +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), +})); + +vi.mock("../auth-context", () => ({ + useAuth: () => ({ register: vi.fn() }), +})); + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => cleanup()); + +test("renders signup form with title", () => { + render(); + expect(screen.getByRole("heading", { name: "Create account" })).toBeDefined(); +}); + +test("renders email and password inputs", () => { + render(); + expect(screen.getByLabelText("Email")).toBeDefined(); + expect(screen.getByLabelText("Password")).toBeDefined(); + expect(screen.getByLabelText("Confirm Password")).toBeDefined(); +}); + +test("renders create account button", () => { + render(); + expect(screen.getByRole("button", { name: "Create account" })).toBeDefined(); +}); + +test("renders link to login", () => { + render(); + expect(screen.getByText("Sign in")).toBeDefined(); +}); diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx new file mode 100644 index 0000000..439b61b --- /dev/null +++ b/frontend/app/signup/page.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "../auth-context"; + +export default function SignupPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const { register } = useAuth(); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (password !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + if (password.length < 6) { + setError("Password must be at least 6 characters"); + return; + } + + setIsSubmitting(true); + + try { + await register(email, password); + router.push("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "Registration failed"); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+
+

Create account

+

Get started with your journey

+
+ +
+ {error &&
{error}
} + +
+ + setEmail(e.target.value)} + style={styles.input} + placeholder="you@example.com" + required + /> +
+ +
+ + setPassword(e.target.value)} + style={styles.input} + placeholder="••••••••" + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + style={styles.input} + placeholder="••••••••" + required + /> +
+ + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+
+ ); +} + +const styles: Record = { + main: { + minHeight: "100vh", + background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "1rem", + }, + container: { + width: "100%", + maxWidth: "420px", + }, + card: { + background: "rgba(255, 255, 255, 0.03)", + backdropFilter: "blur(10px)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "24px", + padding: "3rem 2.5rem", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", + }, + header: { + textAlign: "center" as const, + marginBottom: "2.5rem", + }, + title: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "2.5rem", + fontWeight: 400, + color: "#fff", + margin: 0, + letterSpacing: "-0.02em", + }, + subtitle: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + marginTop: "0.5rem", + fontSize: "0.95rem", + }, + form: { + display: "flex", + flexDirection: "column" as const, + gap: "1.5rem", + }, + field: { + display: "flex", + flexDirection: "column" as const, + gap: "0.5rem", + }, + label: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.7)", + fontSize: "0.875rem", + fontWeight: 500, + }, + input: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.875rem 1rem", + fontSize: "1rem", + background: "rgba(255, 255, 255, 0.05)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "12px", + color: "#fff", + outline: "none", + transition: "border-color 0.2s, box-shadow 0.2s", + }, + button: { + fontFamily: "'DM Sans', system-ui, sans-serif", + marginTop: "0.5rem", + padding: "1rem", + fontSize: "1rem", + fontWeight: 600, + background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)", + color: "#fff", + border: "none", + borderRadius: "12px", + cursor: "pointer", + transition: "transform 0.2s, box-shadow 0.2s", + boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)", + }, + error: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.875rem 1rem", + background: "rgba(239, 68, 68, 0.1)", + border: "1px solid rgba(239, 68, 68, 0.3)", + borderRadius: "12px", + color: "#fca5a5", + fontSize: "0.875rem", + textAlign: "center" as const, + }, + footer: { + fontFamily: "'DM Sans', system-ui, sans-serif", + textAlign: "center" as const, + marginTop: "2rem", + color: "rgba(255, 255, 255, 0.5)", + fontSize: "0.875rem", + }, + link: { + color: "#a78bfa", + textDecoration: "none", + fontWeight: 500, + }, +}; + diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 0000000..b307fda --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,283 @@ +import { test, expect, Page } from "@playwright/test"; + +// Helper to generate unique email for each test +function uniqueEmail(): string { + return `test-${Date.now()}-${Math.random().toString(36).substring(7)}@example.com`; +} + +// Helper to clear localStorage +async function clearAuth(page: Page) { + await page.evaluate(() => localStorage.clear()); +} + +test.describe("Authentication Flow", () => { + test.beforeEach(async ({ page }) => { + await clearAuth(page); + }); + + test("redirects to login when not authenticated", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveURL("/login"); + }); + + test("login page has correct form elements", async ({ page }) => { + await page.goto("/login"); + await expect(page.locator("h1")).toHaveText("Welcome back"); + await expect(page.locator('input[type="email"]')).toBeVisible(); + await expect(page.locator('input[type="password"]')).toBeVisible(); + await expect(page.locator('button[type="submit"]')).toHaveText("Sign in"); + await expect(page.locator('a[href="/signup"]')).toBeVisible(); + }); + + test("signup page has correct form elements", async ({ page }) => { + await page.goto("/signup"); + await expect(page.locator("h1")).toHaveText("Create account"); + await expect(page.locator('input[type="email"]')).toBeVisible(); + await expect(page.locator('input[type="password"]').first()).toBeVisible(); + await expect(page.locator('input[type="password"]').nth(1)).toBeVisible(); + await expect(page.locator('button[type="submit"]')).toHaveText("Create account"); + await expect(page.locator('a[href="/login"]')).toBeVisible(); + }); + + test("can navigate from login to signup", async ({ page }) => { + await page.goto("/login"); + await page.click('a[href="/signup"]'); + await expect(page).toHaveURL("/signup"); + }); + + test("can navigate from signup to login", async ({ page }) => { + await page.goto("/signup"); + await page.click('a[href="/login"]'); + await expect(page).toHaveURL("/login"); + }); +}); + +test.describe("Signup", () => { + test.beforeEach(async ({ page }) => { + await clearAuth(page); + }); + + test("can create a new account", async ({ page }) => { + const email = uniqueEmail(); + + await page.goto("/signup"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', "password123"); + await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.click('button[type="submit"]'); + + // Should redirect to home after signup + await expect(page).toHaveURL("/"); + // Should show user email + await expect(page.getByText(email)).toBeVisible(); + }); + + test("shows error for duplicate email", async ({ page }) => { + const email = uniqueEmail(); + + // First registration + await page.goto("/signup"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', "password123"); + await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/"); + + // Clear and try again with same email + await clearAuth(page); + await page.goto("/signup"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', "password123"); + await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.click('button[type="submit"]'); + + // Should show error + await expect(page.getByText("Email already registered")).toBeVisible(); + }); + + test("shows error for password mismatch", async ({ page }) => { + await page.goto("/signup"); + await page.fill('input[type="email"]', uniqueEmail()); + await page.fill('input[type="password"]', "password123"); + await page.locator('input[type="password"]').nth(1).fill("differentpassword"); + await page.click('button[type="submit"]'); + + await expect(page.getByText("Passwords do not match")).toBeVisible(); + }); + + test("shows error for short password", async ({ page }) => { + await page.goto("/signup"); + await page.fill('input[type="email"]', uniqueEmail()); + await page.fill('input[type="password"]', "short"); + await page.locator('input[type="password"]').nth(1).fill("short"); + await page.click('button[type="submit"]'); + + await expect(page.getByText("Password must be at least 6 characters")).toBeVisible(); + }); + + test("shows loading state while submitting", async ({ page }) => { + await page.goto("/signup"); + await page.fill('input[type="email"]', uniqueEmail()); + await page.fill('input[type="password"]', "password123"); + await page.locator('input[type="password"]').nth(1).fill("password123"); + + // Start submission and check for loading state + const submitPromise = page.click('button[type="submit"]'); + await expect(page.locator('button[type="submit"]')).toHaveText("Creating account..."); + await submitPromise; + }); +}); + +test.describe("Login", () => { + const testEmail = `login-test-${Date.now()}@example.com`; + const testPassword = "testpassword123"; + + test.beforeAll(async ({ browser }) => { + // Create a test user + const page = await browser.newPage(); + await page.goto("/signup"); + await page.fill('input[type="email"]', testEmail); + await page.fill('input[type="password"]', testPassword); + await page.locator('input[type="password"]').nth(1).fill(testPassword); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/"); + await page.close(); + }); + + test.beforeEach(async ({ page }) => { + await clearAuth(page); + }); + + test("can login with valid credentials", async ({ page }) => { + await page.goto("/login"); + await page.fill('input[type="email"]', testEmail); + await page.fill('input[type="password"]', testPassword); + await page.click('button[type="submit"]'); + + await expect(page).toHaveURL("/"); + await expect(page.getByText(testEmail)).toBeVisible(); + }); + + test("shows error for wrong password", async ({ page }) => { + await page.goto("/login"); + await page.fill('input[type="email"]', testEmail); + await page.fill('input[type="password"]', "wrongpassword"); + await page.click('button[type="submit"]'); + + await expect(page.getByText("Incorrect email or password")).toBeVisible(); + }); + + test("shows error for non-existent user", async ({ page }) => { + await page.goto("/login"); + await page.fill('input[type="email"]', "nonexistent@example.com"); + await page.fill('input[type="password"]', "password123"); + await page.click('button[type="submit"]'); + + await expect(page.getByText("Incorrect email or password")).toBeVisible(); + }); + + test("shows loading state while submitting", async ({ page }) => { + await page.goto("/login"); + await page.fill('input[type="email"]', testEmail); + await page.fill('input[type="password"]', testPassword); + + const submitPromise = page.click('button[type="submit"]'); + await expect(page.locator('button[type="submit"]')).toHaveText("Signing in..."); + await submitPromise; + }); +}); + +test.describe("Logout", () => { + test("can logout", async ({ page }) => { + const email = uniqueEmail(); + + // Sign up first + await page.goto("/signup"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', "password123"); + await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/"); + + // Click logout + await page.click("text=Sign out"); + + // Should redirect to login + await expect(page).toHaveURL("/login"); + }); + + test("cannot access home after logout", async ({ page }) => { + const email = uniqueEmail(); + + // Sign up + await page.goto("/signup"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', "password123"); + await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/"); + + // Logout + await page.click("text=Sign out"); + await expect(page).toHaveURL("/login"); + + // Try to access home + await page.goto("/"); + await expect(page).toHaveURL("/login"); + }); +}); + +test.describe("Session Persistence", () => { + test("session persists after page reload", async ({ page }) => { + const email = uniqueEmail(); + + // Sign up + await page.goto("/signup"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', "password123"); + await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/"); + await expect(page.getByText(email)).toBeVisible(); + + // Reload page + await page.reload(); + + // Should still be logged in + await expect(page).toHaveURL("/"); + await expect(page.getByText(email)).toBeVisible(); + }); + + test("token is stored in localStorage", async ({ page }) => { + const email = uniqueEmail(); + + await page.goto("/signup"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', "password123"); + await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/"); + + // Check localStorage + const token = await page.evaluate(() => localStorage.getItem("token")); + expect(token).toBeTruthy(); + expect(token!.length).toBeGreaterThan(10); + }); + + test("token is cleared on logout", async ({ page }) => { + const email = uniqueEmail(); + + await page.goto("/signup"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', "password123"); + await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/"); + + await page.click("text=Sign out"); + + const token = await page.evaluate(() => localStorage.getItem("token")); + expect(token).toBeNull(); + }); +}); + diff --git a/frontend/e2e/counter.spec.ts b/frontend/e2e/counter.spec.ts index 575b848..1f395d8 100644 --- a/frontend/e2e/counter.spec.ts +++ b/frontend/e2e/counter.spec.ts @@ -1,29 +1,148 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, Page } from "@playwright/test"; -test("displays counter value", async ({ page }) => { - await page.goto("/"); - await expect(page.locator("h1")).not.toHaveText("..."); +// Helper to generate unique email for each test +function uniqueEmail(): string { + return `counter-${Date.now()}-${Math.random().toString(36).substring(7)}@example.com`; +} + +// Helper to authenticate a user +async function authenticate(page: Page): Promise { + const email = uniqueEmail(); + await page.evaluate(() => localStorage.clear()); + await page.goto("/signup"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', "password123"); + await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/"); + return email; +} + +test.describe("Counter - Authenticated", () => { + test("displays counter value", async ({ page }) => { + await authenticate(page); + await expect(page.locator("h1")).toBeVisible(); + // Counter should be a number (not loading state) + const text = await page.locator("h1").textContent(); + expect(text).toMatch(/^\d+$/); + }); + + test("displays current count label", async ({ page }) => { + await authenticate(page); + await expect(page.getByText("Current Count")).toBeVisible(); + }); + + test("clicking increment button increases counter", async ({ page }) => { + await authenticate(page); + await expect(page.locator("h1")).not.toHaveText("..."); + + const before = await page.locator("h1").textContent(); + await page.click("text=Increment"); + await expect(page.locator("h1")).toHaveText(String(Number(before) + 1)); + }); + + test("clicking increment multiple times", async ({ page }) => { + await authenticate(page); + await expect(page.locator("h1")).not.toHaveText("..."); + + const before = Number(await page.locator("h1").textContent()); + await page.click("text=Increment"); + await page.click("text=Increment"); + await page.click("text=Increment"); + await expect(page.locator("h1")).toHaveText(String(before + 3)); + }); + + test("counter persists after page reload", async ({ page }) => { + await authenticate(page); + await expect(page.locator("h1")).not.toHaveText("..."); + + const before = await page.locator("h1").textContent(); + await page.click("text=Increment"); + const expected = String(Number(before) + 1); + await expect(page.locator("h1")).toHaveText(expected); + + await page.reload(); + await expect(page.locator("h1")).toHaveText(expected); + }); + + test("counter is shared between users", async ({ page, browser }) => { + // First user increments + await authenticate(page); + await expect(page.locator("h1")).not.toHaveText("..."); + + const initialValue = Number(await page.locator("h1").textContent()); + await page.click("text=Increment"); + await page.click("text=Increment"); + const afterFirst = initialValue + 2; + await expect(page.locator("h1")).toHaveText(String(afterFirst)); + + // Second user in new context sees the same value + const page2 = await browser.newPage(); + await authenticate(page2); + await expect(page2.locator("h1")).toHaveText(String(afterFirst)); + + // Second user increments + await page2.click("text=Increment"); + await expect(page2.locator("h1")).toHaveText(String(afterFirst + 1)); + + // First user reloads and sees the increment + await page.reload(); + await expect(page.locator("h1")).toHaveText(String(afterFirst + 1)); + + await page2.close(); + }); }); -test("clicking +1 increments the counter", async ({ page }) => { - await page.goto("/"); - await expect(page.locator("h1")).not.toHaveText("..."); +test.describe("Counter - Unauthenticated", () => { + test("redirects to login when accessing counter without auth", async ({ page }) => { + await page.evaluate(() => localStorage.clear()); + await page.goto("/"); + await expect(page).toHaveURL("/login"); + }); - const before = await page.locator("h1").textContent(); - await page.click("button"); - await expect(page.locator("h1")).toHaveText(String(Number(before) + 1)); + test("shows login form when redirected", async ({ page }) => { + await page.evaluate(() => localStorage.clear()); + await page.goto("/"); + await expect(page.locator("h1")).toHaveText("Welcome back"); + }); }); -test("counter persists after reload", async ({ page }) => { - await page.goto("/"); - await expect(page.locator("h1")).not.toHaveText("..."); +test.describe("Counter - Session Integration", () => { + test("can access counter after login", async ({ page }) => { + const email = uniqueEmail(); - const before = await page.locator("h1").textContent(); - await page.click("button"); - const expected = String(Number(before) + 1); - await expect(page.locator("h1")).toHaveText(expected); + // Sign up first + await page.goto("/signup"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', "password123"); + await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/"); - await page.reload(); - await expect(page.locator("h1")).toHaveText(expected); + // Logout + await page.click("text=Sign out"); + await expect(page).toHaveURL("/login"); + + // Login again + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', "password123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/"); + + // Counter should be visible + await expect(page.locator("h1")).toBeVisible(); + const text = await page.locator("h1").textContent(); + expect(text).toMatch(/^\d+$/); + }); + + test("counter API requires authentication", async ({ page }) => { + // Try to access counter API directly without auth + const response = await page.request.get("http://localhost:8000/api/counter"); + expect(response.status()).toBe(403); + }); + + test("counter increment API requires authentication", async ({ page }) => { + const response = await page.request.post("http://localhost:8000/api/counter/increment"); + expect(response.status()).toBe(403); + }); }); -