diff --git a/.env b/.env deleted file mode 100644 index cec81f7..0000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -BASE_URL=http://localhost:8000 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9aaeae7..1bbc918 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ app/__pycache__ +__pycache__ +.pytest_cache/ venv/ -test.txt \ No newline at end of file +test.txt +diff.txt \ No newline at end of file diff --git a/README.md b/README.md index dfe6b60..5dc2252 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ source venv/bin/activate ## Run locally ```bash -uvicorn app.main:app --reload +python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ``` Then check:
http://127.0.0.1:8000
@@ -23,5 +23,11 @@ http://127.0.0.1:8000/shorten ```bash PYTHONPATH=./ pytest export PYTHONPATH=$(pwd) -pytest - ``` \ No newline at end of file +python -m pytest --cov=app --cov-report=term-missing +``` + +## Running Alembic revision +```bash +cd /opt/kjc/int/URL-shortener +alembic revision --autogenerate -m "create urls table" +``` \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..f29c9f9 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,118 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +version_path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:///./test.db + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..4125426 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,82 @@ +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig + +from app.db.base import Base +from app.db import models +from app.core.config import settings + +target_metadata = Base.metadata + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + + config.set_main_option("sqlalchemy.url", settings.database_url) + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0132ba841ec5_create_urls_table.py b/alembic/versions/0132ba841ec5_create_urls_table.py new file mode 100644 index 0000000..a6633ce --- /dev/null +++ b/alembic/versions/0132ba841ec5_create_urls_table.py @@ -0,0 +1,30 @@ +"""create urls table + +Revision ID: 0132ba841ec5 +Revises: +Create Date: 2026-03-02 09:13:50.764599 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0132ba841ec5' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..164d9e7 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,70 @@ +# app/api/routes.py + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.url import ( + URLCreate, + URLResponse, + URLStatsResponse, + MessageResponse, +) +from app.services import url_service + + +router = APIRouter() + + +@router.get("/", response_model=MessageResponse) +def home(): + return {"message": "URL Shortener API"} + + +@router.post("/shorten", response_model=URLResponse) +def shorten_url( + request: URLCreate, + db: Session = Depends(get_db), +): + short_url = url_service.create_short_url(db, str(request.url)) + return {"short_url": short_url} + + +@router.get("/{short_code}") +def redirect_to_url( + short_code: str, + db: Session = Depends(get_db), +): + original_url = url_service.get_original_url(db, short_code) + + if not original_url: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Shortened URL not found", + ) + + url_service.increment_clicks(db, short_code) + + return RedirectResponse(url=original_url) + + +@router.get("/stats/{short_code}", response_model=URLStatsResponse) +def get_url_stats( + short_code: str, + db: Session = Depends(get_db), +): + stats = url_service.get_stats(db, short_code) + + if not stats: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="URL stats not found", + ) + + return stats + + +@router.get("/health", response_model=MessageResponse) +def health_check(): + return {"message": "healthy"} \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..ec944bf --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,38 @@ +# app/core/config.py + +from functools import lru_cache +from pydantic_settings import BaseSettings +from typing import List + + +class Settings(BaseSettings): + app_name: str = "URL Shortener" + allowed_hosts: List[str] = ["*"] + database_url: str + + # Server settings + host: str = "0.0.0.0" + port: int = 8000 + base_url: str = "http://localhost:8000" + + # Debug flag + debug: bool = True + + # Optional future extensions + rate_limit_per_minute: int = 60 + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + case_sensitive = False + extra = "ignore" + + +# Cached singleton-style access +@lru_cache() +def get_settings() -> Settings: + return Settings() + + +# Global settings instance +settings = get_settings() \ No newline at end of file diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..b08ce6a --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,6 @@ +# app/db/base.py + +from sqlalchemy.orm import DeclarativeBase + +class Base(DeclarativeBase): + pass \ No newline at end of file diff --git a/app/db/models.py b/app/db/models.py new file mode 100644 index 0000000..f3c37e5 --- /dev/null +++ b/app/db/models.py @@ -0,0 +1,57 @@ +# app/db/models.py + +from sqlalchemy import String, Integer, DateTime, func, Index +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from app.db.base import Base +from typing import Optional + +class URL(Base): + __tablename__ = "urls" + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + index=True + ) + + short_code: Mapped[str] = mapped_column( + String(10), + unique=True, + nullable=False, + index=True + ) + + original_url: Mapped[str] = mapped_column( + String(2048), + nullable=False, + index=True + ) + + clicks: Mapped[int] = mapped_column( + Integer, + default=0, + nullable=False + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False + ) + + last_accessed: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True + ) + + # Additional compound index for faster lookups + __table_args__ = ( + Index("idx_short_original", "short_code", "original_url"), + ) + + def __repr__(self) -> str: + return ( + f"" + ) \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..9619a29 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,26 @@ +# app/db/session.py + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.core.config import settings + +# Create SQLAlchemy engine +engine = create_engine( + settings.database_url, + pool_pre_ping=True, # helps prevent stale connections +) + +# Session factory +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, +) + +# Dependency for FastAPI +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/main.py b/app/main.py index 744d739..256bcea 100644 --- a/app/main.py +++ b/app/main.py @@ -1,124 +1,28 @@ -from fastapi import FastAPI, HTTPException -from fastapi.responses import RedirectResponse +# app/main.py + +from fastapi import FastAPI from contextlib import asynccontextmanager -from app.utils import generate_short_code -from pydantic import BaseModel, HttpUrl -from pydantic_settings import BaseSettings -import sqlite3 -import logging +from app.core.config import settings +from app.db.session import engine +from app.db.base import Base +from app.api.routes import router -class AppSettings(BaseSettings): - base_url: str = "http://localhost:8000" - database_url: str = "urls.db" -settings = AppSettings() # Load environment variables - -# Set up logging configuration -logging.basicConfig(level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s") -logger = logging.getLogger(__name__) - -# Database connection setup @asynccontextmanager async def lifespan(app: FastAPI): - conn = sqlite3.connect(settings.database_url) - conn.execute(""" - CREATE TABLE IF NOT EXISTS urls ( - id INTEGER PRIMARY KEY, - short_code TEXT UNIQUE, - original_url TEXT NOT NULL, - clicks INTEGER DEFAULT 0 - ); - """) - conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_short_code ON urls(short_code); - """) - conn.commit() - logger.info("Database initialized or already exists.") + + Base.metadata.create_all(bind=engine) yield - conn.close() - logger.info("Database connection closed.") -app = FastAPI(lifespan=lifespan) - -def get_db_connection(): - return sqlite3.connect(settings.database_url, check_same_thread=False) - -class URLRequest(BaseModel): - url: HttpUrl - -@app.get("/") -def home(): - logger.info("Home endpoint accessed.") - return {"message": "URL Shortener API"} - -@app.post("/shorten") -def shorten_url(request: URLRequest): - conn = get_db_connection() - - existing = conn.execute( - "SELECT short_code FROM urls WHERE original_url = ?", - (str(request.url),) - ).fetchone() - - if existing: - conn.close() - return {"short_url": f"{settings.base_url}/{existing[0]}"} - - short_code = generate_short_code() - while conn.execute( - "SELECT 1 FROM urls WHERE short_code = ?", - (short_code,) - ).fetchone(): - short_code = generate_short_code() - - conn.execute( - "INSERT INTO urls (short_code, original_url, clicks) VALUES (?, ?, 0)", - (short_code, str(request.url)) +def create_app() -> FastAPI: + app = FastAPI( + title=settings.app_name, + debug=settings.debug, ) - conn.commit() - conn.close() - return {"short_url": f"{settings.base_url}/{short_code}"} + app.include_router(router) -@app.get("/{short_code}") -def redirect_to_url(short_code: str): - conn = get_db_connection() - url_data = conn.execute( - "SELECT original_url FROM urls WHERE short_code = ?", - (short_code,) - ).fetchone() + return app - if url_data is None: - conn.close() - raise HTTPException(status_code=404, detail="Shortened URL not found") - - original_url = url_data[0] - - conn.execute( - "UPDATE urls SET clicks = clicks + 1 WHERE short_code = ?", - (short_code,) - ) - conn.commit() - conn.close() - - return RedirectResponse(url=original_url) - -@app.get("/stats/{short_code}") -def get_url_stats(short_code: str): - logger.info(f"Stats request received for short_code: {short_code}") - conn = get_db_connection() - url_data = conn.execute("SELECT original_url, clicks FROM urls WHERE short_code = ?", (short_code,)).fetchone() - - if url_data is None: - logger.warning(f"Stats not found for {short_code}.") - conn.close() - raise HTTPException(status_code=404, detail="URL stats not found") - - original_url, clicks = url_data - - conn.close() - - logger.info(f"Returning stats for {original_url}. Total clicks: {clicks}") - - return {"original_url": original_url, "clicks": clicks} \ No newline at end of file +app = create_app() +app.router.lifespan_context = lifespan \ No newline at end of file diff --git a/app/schemas/url.py b/app/schemas/url.py new file mode 100644 index 0000000..ab24c28 --- /dev/null +++ b/app/schemas/url.py @@ -0,0 +1,24 @@ +# app/schemas/url.py + +from pydantic import BaseModel, HttpUrl +from datetime import datetime +from typing import Optional + + +class URLCreate(BaseModel): + url: HttpUrl + + +class URLResponse(BaseModel): + short_url: str + + +class URLStatsResponse(BaseModel): + original_url: HttpUrl + clicks: int + created_at: datetime + last_accessed: Optional[datetime] = None + + +class MessageResponse(BaseModel): + message: str \ No newline at end of file diff --git a/app/services/url_service.py b/app/services/url_service.py new file mode 100644 index 0000000..7c2300d --- /dev/null +++ b/app/services/url_service.py @@ -0,0 +1,103 @@ +# app/services/url_service.py + +from sqlalchemy.orm import Session +from sqlalchemy import select +from datetime import datetime +from typing import Optional + +from app.db.models import URL +from app.utils.short_code import generate_short_code +from app.core.config import settings + +SHORT_CODE_LENGTH = 6 +MAX_GENERATION_ATTEMPTS = 5 + + +def create_short_url(db: Session, original_url: str) -> str: + """ + Create a shortened URL or return existing one if already present. + """ + + # 1️⃣ Check if URL already exists + existing = db.execute( + select(URL).where(URL.original_url == original_url) + ).scalar_one_or_none() + + if existing: + return f"{settings.base_url}/{existing.short_code}" + + # 2️⃣ Generate unique short code with collision handling + for _ in range(MAX_GENERATION_ATTEMPTS): + short_code = generate_short_code(SHORT_CODE_LENGTH) + + collision = db.execute( + select(URL).where(URL.short_code == short_code) + ).scalar_one_or_none() + + if not collision: + new_url = URL( + short_code=short_code, + original_url=original_url, + ) + db.add(new_url) + db.commit() + db.refresh(new_url) + + return f"{settings.base_url}/{new_url.short_code}" + + # If we somehow fail multiple times + raise RuntimeError("Failed to generate unique short code") + + +def get_original_url(db: Session, short_code: str) -> Optional[str]: + """ + Retrieve original URL for redirection. + """ + + url_entry = db.execute( + select(URL).where(URL.short_code == short_code) + ).scalar_one_or_none() + + if not url_entry: + return None + + return url_entry.original_url + + +def increment_clicks(db: Session, short_code: str) -> bool: + """ + Increment click counter and update last_accessed timestamp. + """ + + url_entry = db.execute( + select(URL).where(URL.short_code == short_code) + ).scalar_one_or_none() + + if not url_entry: + return False + + url_entry.clicks += 1 + url_entry.last_accessed = datetime.utcnow() + + db.commit() + return True + + +def get_stats(db: Session, short_code: str) -> Optional[dict]: + """ + Return URL statistics. + """ + + url_entry = db.execute( + select(URL).where(URL.short_code == short_code) + ).scalar_one_or_none() + + if not url_entry: + return None + + return { + "original_url": url_entry.original_url, + "clicks": url_entry.clicks, + "created_at": url_entry.created_at, + "last_accessed": url_entry.last_accessed, + } \ No newline at end of file diff --git a/app/settings.py b/app/settings.py deleted file mode 100644 index 3883334..0000000 --- a/app/settings.py +++ /dev/null @@ -1,7 +0,0 @@ -# app/settings.py - -import os - -# Example configuration -database_url = os.getenv("DATABASE_URL", "sqlite:///default.db") -debug = os.getenv("DEBUG", "True") == "True" \ No newline at end of file diff --git a/app/test_main.py b/app/test_main.py deleted file mode 100644 index 9355bf4..0000000 --- a/app/test_main.py +++ /dev/null @@ -1,66 +0,0 @@ -from fastapi.testclient import TestClient -import sys -import os - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from app.main import app -from app import settings -import tempfile -import pytest - -@pytest.fixture -def test_client(): - with tempfile.NamedTemporaryFile() as tmp: - settings.database_url = tmp.name - with TestClient(app) as c: - yield c - -@pytest.fixture -def use_temp_db(): - with tempfile.NamedTemporaryFile() as tmp: - settings.database_url = tmp.name - yield - -def test_home(test_client): - response = test_client.get("/") - assert response.status_code == 200 - assert response.json() == {"message": "URL Shortener API"} - -def test_shorten_url(test_client): - response = test_client.post("/shorten", json={"url": "https://google.com"}) - assert response.status_code == 200 - data = response.json() - assert "short_url" in data - short_url = data["short_url"] - assert short_url.startswith("http://localhost:8000/") - -def test_redirect(test_client): - response = test_client.post("/shorten", json={"url": "https://google.com"}) - short_url = response.json()["short_url"] - code = short_url.split("/")[-1] - - redirect = test_client.get(f"/{code}", follow_redirects=False) - assert redirect.status_code == 307 - -def test_duplicate_url_returns_same_code(test_client): - r1 = test_client.post("/shorten", json={"url": "https://example.com"}) - r2 = test_client.post("/shorten", json={"url": "https://example.com"}) - - assert r1.status_code == 200 - assert r2.status_code == 200 - assert r1.json()["short_url"] == r2.json()["short_url"] - -def test_stats_endpoint(test_client): - response = test_client.post("/shorten", json={"url": "https://stats-test.com"}) - code = response.json()["short_url"].split("/")[-1] - - test_client.get(f"/{code}", follow_redirects=False) - - stats = test_client.get(f"/stats/{code}") - assert stats.status_code == 200 - data = stats.json() - assert data["clicks"] == 1 - -def test_redirect_404(test_client): - response = test_client.get("/nonexistent", follow_redirects=False) - assert response.status_code == 404 \ No newline at end of file diff --git a/app/tests/conftest.py b/app/tests/conftest.py new file mode 100644 index 0000000..55dffc8 --- /dev/null +++ b/app/tests/conftest.py @@ -0,0 +1,64 @@ +# app/tests/conftest.py + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from fastapi.testclient import TestClient + +from app.main import app +from app.db.base import Base +from app.db.session import get_db + + +# Use in-memory SQLite for tests +TEST_DATABASE_URL = "sqlite+pysqlite:///:memory:" + + +@pytest.fixture(scope="function") +def db_session(): + """ + Creates a new database session for a test. + Rolls back everything after test finishes. + """ + + engine = create_engine( + TEST_DATABASE_URL, + connect_args={"check_same_thread": False}, + ) + + TestingSessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, + ) + + # Create tables + Base.metadata.create_all(bind=engine) + + db = TestingSessionLocal() + + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture(scope="function") +def client(db_session): + """ + Overrides get_db dependency to use test database. + """ + + def override_get_db(): + try: + yield db_session + finally: + pass + + app.dependency_overrides[get_db] = override_get_db + + with TestClient(app) as c: + yield c + + app.dependency_overrides.clear() \ No newline at end of file diff --git a/app/tests/test_routes.py b/app/tests/test_routes.py new file mode 100644 index 0000000..c1edf37 --- /dev/null +++ b/app/tests/test_routes.py @@ -0,0 +1,93 @@ +# app/tests/test_routes.py + +import pytest + + +def test_home(client): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "URL Shortener API"} + + +def test_health(client): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"message": "healthy"} + + +def test_shorten_url(client): + response = client.post( + "/shorten", + json={"url": "https://google.com"}, + ) + + assert response.status_code == 200 + data = response.json() + + assert "short_url" in data + assert data["short_url"].startswith("http://") + + +def test_shorten_invalid_url(client): + response = client.post( + "/shorten", + json={"url": "not-a-valid-url"}, + ) + + assert response.status_code == 422 # Pydantic validation error + + +def test_duplicate_url_returns_same_code(client): + r1 = client.post("/shorten", json={"url": "https://example.com"}) + r2 = client.post("/shorten", json={"url": "https://example.com"}) + + assert r1.status_code == 200 + assert r2.status_code == 200 + + assert r1.json()["short_url"] == r2.json()["short_url"] + + +def test_redirect_success(client): + shorten = client.post( + "/shorten", + json={"url": "https://redirect-test.com"}, + ) + + short_url = shorten.json()["short_url"] + code = short_url.split("/")[-1] + + response = client.get(f"/{code}", follow_redirects=False) + + assert response.status_code in (302, 307) + + +def test_redirect_404(client): + response = client.get("/nonexistent", follow_redirects=False) + assert response.status_code == 404 + + +def test_stats_success(client): + shorten = client.post( + "/shorten", + json={"url": "https://stats-test.com"}, + ) + + short_url = shorten.json()["short_url"] + code = short_url.split("/")[-1] + + # Trigger one click + client.get(f"/{code}", follow_redirects=False) + + stats = client.get(f"/stats/{code}") + + assert stats.status_code == 200 + data = stats.json() + + assert data["original_url"] == "https://stats-test.com" + assert data["clicks"] == 1 + assert "created_at" in data + + +def test_stats_404(client): + response = client.get("/stats/doesnotexist") + assert response.status_code == 404 \ No newline at end of file diff --git a/app/utils.py b/app/utils.py index 785e0b4..efe5e4f 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,3 +1,5 @@ +# app/utils.py + import string import random diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/short_code.py b/app/utils/short_code.py new file mode 100644 index 0000000..54cc355 --- /dev/null +++ b/app/utils/short_code.py @@ -0,0 +1,8 @@ +# app/utils/short_code.py +import string +import random + +def generate_short_code(length: int = 6) -> str: + """Generate a random alphanumeric short code.""" + chars = string.ascii_letters + string.digits + return ''.join(random.choices(chars, k=length)) \ No newline at end of file diff --git a/test.db b/test.db new file mode 100644 index 0000000..32c0952 Binary files /dev/null and b/test.db differ diff --git a/urls.db b/urls.db deleted file mode 100644 index 32dc3f9..0000000 Binary files a/urls.db and /dev/null differ