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