tests passing

This commit is contained in:
counterweight 2025-12-18 22:08:31 +01:00
parent 0995e1cc77
commit 7ebfb7a2dd
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
20 changed files with 2009 additions and 126 deletions

View file

@ -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

100
backend/auth.py Normal file
View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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]

View file

@ -1,4 +1,3 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function

35
backend/tests/conftest.py Normal file
View file

@ -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()

282
backend/tests/test_auth.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(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 (
<AuthContext.Provider value={{ user, token, isLoading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View file

@ -1,8 +1,44 @@
import { AuthProvider } from "./auth-context";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Serif:ital@0;1&display=swap"
rel="stylesheet"
/>
<style>{`
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'DM Sans', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
input:focus {
border-color: rgba(139, 92, 246, 0.5) !important;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1) !important;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
}
button:active:not(:disabled) {
transform: translateY(0);
}
a:hover {
text-decoration: underline;
}
`}</style>
</head>
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}

View file

@ -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(<LoginPage />);
expect(screen.getByText("Welcome back")).toBeDefined();
});
test("renders email and password inputs", () => {
render(<LoginPage />);
expect(screen.getByLabelText("Email")).toBeDefined();
expect(screen.getByLabelText("Password")).toBeDefined();
});
test("renders sign in button", () => {
render(<LoginPage />);
expect(screen.getByRole("button", { name: "Sign in" })).toBeDefined();
});
test("renders link to signup", () => {
render(<LoginPage />);
expect(screen.getByText("Sign up")).toBeDefined();
});

195
frontend/app/login/page.tsx Normal file
View file

@ -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 (
<main style={styles.main}>
<div style={styles.container}>
<div style={styles.card}>
<div style={styles.header}>
<h1 style={styles.title}>Welcome back</h1>
<p style={styles.subtitle}>Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} style={styles.form}>
{error && <div style={styles.error}>{error}</div>}
<div style={styles.field}>
<label htmlFor="email" style={styles.label}>Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={styles.input}
placeholder="you@example.com"
required
/>
</div>
<div style={styles.field}>
<label htmlFor="password" style={styles.label}>Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={styles.input}
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
style={{
...styles.button,
opacity: isSubmitting ? 0.7 : 1,
}}
disabled={isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</button>
</form>
<p style={styles.footer}>
Don&apos;t have an account?{" "}
<a href="/signup" style={styles.link}>
Sign up
</a>
</p>
</div>
</div>
</main>
);
}
const styles: Record<string, React.CSSProperties> = {
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,
},
};

View file

@ -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(<Home />);
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(<Home />);
expect(screen.getByText("Loading...")).toBeDefined();
});
render(<Home />);
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(<Home />);
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(<Home />);
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(<Home />);
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(<Home />);
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(<Home />);
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(<Home />);
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(<Home />);
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(<Home />);
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(<Home />);
expect(screen.getByText("+1")).toBeDefined();
});
render(<Home />);
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(<Home />);
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(<Home />);
// 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(<Home />);
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(<Home />);
await waitFor(() => expect(screen.getByText("0")).toBeDefined());
render(<Home />);
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(<Home />);
expect(screen.getByText("Loading...")).toBeDefined();
});
});

View file

@ -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<number | null>(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 (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
</main>
);
}
if (!user) {
return null;
}
return (
<main style={{ padding: "2rem", fontFamily: "system-ui", textAlign: "center" }}>
<h1 style={{ fontSize: "6rem", margin: "2rem 0" }}>
{count === null ? "..." : count}
</h1>
<button
onClick={increment}
style={{
fontSize: "1.5rem",
padding: "1rem 2rem",
cursor: "pointer",
}}
>
+1
</button>
<main style={styles.main}>
<div style={styles.header}>
<div style={styles.userInfo}>
<span style={styles.userEmail}>{user.email}</span>
<button onClick={logout} style={styles.logoutBtn}>
Sign out
</button>
</div>
</div>
<div style={styles.content}>
<div style={styles.counterCard}>
<span style={styles.counterLabel}>Current Count</span>
<h1 style={styles.counter}>{count === null ? "..." : count}</h1>
<button onClick={increment} style={styles.incrementBtn}>
<span style={styles.plusIcon}>+</span>
Increment
</button>
</div>
</div>
</main>
);
}
const styles: Record<string, React.CSSProperties> = {
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,
},
};

View file

@ -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(<SignupPage />);
expect(screen.getByRole("heading", { name: "Create account" })).toBeDefined();
});
test("renders email and password inputs", () => {
render(<SignupPage />);
expect(screen.getByLabelText("Email")).toBeDefined();
expect(screen.getByLabelText("Password")).toBeDefined();
expect(screen.getByLabelText("Confirm Password")).toBeDefined();
});
test("renders create account button", () => {
render(<SignupPage />);
expect(screen.getByRole("button", { name: "Create account" })).toBeDefined();
});
test("renders link to login", () => {
render(<SignupPage />);
expect(screen.getByText("Sign in")).toBeDefined();
});

View file

@ -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 (
<main style={styles.main}>
<div style={styles.container}>
<div style={styles.card}>
<div style={styles.header}>
<h1 style={styles.title}>Create account</h1>
<p style={styles.subtitle}>Get started with your journey</p>
</div>
<form onSubmit={handleSubmit} style={styles.form}>
{error && <div style={styles.error}>{error}</div>}
<div style={styles.field}>
<label htmlFor="email" style={styles.label}>Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={styles.input}
placeholder="you@example.com"
required
/>
</div>
<div style={styles.field}>
<label htmlFor="password" style={styles.label}>Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={styles.input}
placeholder="••••••••"
required
/>
</div>
<div style={styles.field}>
<label htmlFor="confirmPassword" style={styles.label}>Confirm Password</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
style={styles.input}
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
style={{
...styles.button,
opacity: isSubmitting ? 0.7 : 1,
}}
disabled={isSubmitting}
>
{isSubmitting ? "Creating account..." : "Create account"}
</button>
</form>
<p style={styles.footer}>
Already have an account?{" "}
<a href="/login" style={styles.link}>
Sign in
</a>
</p>
</div>
</div>
</main>
);
}
const styles: Record<string, React.CSSProperties> = {
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,
},
};

283
frontend/e2e/auth.spec.ts Normal file
View file

@ -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();
});
});

View file

@ -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<string> {
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);
});
});