API: Standardized JSON list responses with Pydantic schemas and Pagination; add sort_by/sort_dir validation with whitelists; consistent JSON 401 for /api/*; structured logging for sorting/pagination; add pydantic dep; add Docker smoke script and README docs.
This commit is contained in:
33
README.md
33
README.md
@@ -82,6 +82,39 @@ curl http://localhost:8000/health
|
||||
|
||||
4. Access the API at `http://localhost:8000`
|
||||
|
||||
## API JSON Lists and Sorting
|
||||
|
||||
The following list endpoints return standardized JSON with a shared `pagination` envelope and Pydantic models:
|
||||
|
||||
- `GET /api/rolodex` → items: `ClientOut[]`
|
||||
- `GET /api/files` → items: `CaseOut[]`
|
||||
- `GET /api/ledger` → items: `TransactionOut[]`
|
||||
|
||||
Common query params:
|
||||
- `page` (>=1), `page_size` (1..100 or 200 for ledger)
|
||||
- `sort_by` (endpoint-specific whitelist)
|
||||
- `sort_dir` (`asc` | `desc`)
|
||||
|
||||
If `sort_by` is invalid or `sort_dir` is not one of `asc|desc`, the API returns `400` with details. Dates are ISO-8601 strings, and nulls are preserved as `null`.
|
||||
|
||||
Authentication: Unauthenticated requests to `/api/*` return a JSON `401` with `{ "detail": "Unauthorized" }`.
|
||||
|
||||
### Sorting whitelists
|
||||
- `/api/rolodex`: `id, rolodex_id, last_name, first_name, company, created_at`
|
||||
- `/api/files`: `file_no, status, case_type, description, open_date, close_date, created_at, client_last_name, client_first_name, client_company, id`
|
||||
- `/api/ledger`: `transaction_date, item_no, id, amount, billed, t_code, t_type_l, employee_number, case_file_no, case_id`
|
||||
|
||||
## Docker smoke script
|
||||
|
||||
A simple curl-based smoke script is available:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose exec delphi-db bash -lc "bash scripts/smoke.sh"
|
||||
```
|
||||
|
||||
Note: For authenticated API calls, log in at `/login` via the browser to create a session cookie, then copy your session cookie to a `cookies.txt` file for curl usage.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
app/__pycache__/schemas.cpython-313.pyc
Normal file
BIN
app/__pycache__/schemas.cpython-313.pyc
Normal file
Binary file not shown.
376
app/main.py
376
app/main.py
@@ -16,7 +16,7 @@ from typing import Optional, List, Dict, Any
|
||||
from io import StringIO
|
||||
|
||||
from fastapi import FastAPI, Depends, Request, Query, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from fastapi.responses import RedirectResponse, Response, JSONResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -32,6 +32,16 @@ from .database import create_tables, get_db, get_database_url
|
||||
from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog
|
||||
from .auth import authenticate_user, get_current_user_from_session
|
||||
from .logging_config import setup_logging
|
||||
from .schemas import (
|
||||
ClientOut,
|
||||
PhoneOut,
|
||||
CaseOut,
|
||||
TransactionOut,
|
||||
Pagination,
|
||||
RolodexListResponse,
|
||||
FilesListResponse,
|
||||
LedgerListResponse,
|
||||
)
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
@@ -73,6 +83,9 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# Enforce authentication for other paths
|
||||
if not request.session.get("user_id"):
|
||||
# Return JSON 401 for API routes, redirect for HTML routes
|
||||
if path.startswith("/api/"):
|
||||
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
return await call_next(request)
|
||||
@@ -2185,3 +2198,364 @@ async def phone_book_report(
|
||||
"report_phone_book.html",
|
||||
{"request": request, "user": user, "clients": clients, "q": q, "client_ids": client_ids or []},
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# JSON API: list/filter endpoints
|
||||
# ------------------------------
|
||||
|
||||
def _apply_sorting(query, sort_by: str | None, sort_dir: str, allowed_map: dict[str, Any], default_order: list[Any]):
|
||||
"""Apply validated sorting to a SQLAlchemy query.
|
||||
|
||||
Args:
|
||||
query: Base SQLAlchemy query object
|
||||
sort_by: Optional requested sort field
|
||||
sort_dir: 'asc' or 'desc'
|
||||
allowed_map: Map of allowed sort_by -> SQLAlchemy column or list of columns
|
||||
default_order: Fallback order_by list when sort_by is not provided
|
||||
|
||||
Returns:
|
||||
(query, applied_sort_by, applied_sort_dir)
|
||||
"""
|
||||
if not sort_by:
|
||||
for col in default_order:
|
||||
query = query.order_by(col)
|
||||
return query, None, sort_dir
|
||||
|
||||
column_expr = allowed_map.get(sort_by)
|
||||
if column_expr is None:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid sort_by: '{sort_by}'. Allowed: {sorted(list(allowed_map.keys()))}")
|
||||
|
||||
def _order(expr):
|
||||
return expr.asc().nulls_last() if sort_dir == "asc" else expr.desc().nulls_last()
|
||||
|
||||
if isinstance(column_expr, (list, tuple)):
|
||||
for expr in column_expr:
|
||||
query = query.order_by(_order(expr))
|
||||
else:
|
||||
query = query.order_by(_order(column_expr))
|
||||
|
||||
return query, sort_by, sort_dir
|
||||
|
||||
@app.get("/api/rolodex", response_model=RolodexListResponse)
|
||||
async def api_list_rolodex(
|
||||
request: Request,
|
||||
q: str | None = Query(None, description="Search by first/last/company contains"),
|
||||
phone: str | None = Query(None, description="Phone number contains"),
|
||||
rolodex_id: str | None = Query(None, description="Legacy Rolodex ID contains"),
|
||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Results per page"),
|
||||
sort_by: str | None = Query(None, description="Sort field: id, rolodex_id, last_name, first_name, company, created_at"),
|
||||
sort_dir: str = Query("asc", description="Sort direction: asc or desc"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> RolodexListResponse:
|
||||
"""Return paginated clients with simple filters as JSON."""
|
||||
user = get_current_user_from_session(request.session)
|
||||
if not user:
|
||||
# Middleware ensures JSON 401 for /api/*, keep explicit for clarity
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
query = db.query(Client).options(joinedload(Client.phones))
|
||||
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Client.first_name.ilike(like),
|
||||
Client.last_name.ilike(like),
|
||||
Client.company.ilike(like),
|
||||
)
|
||||
)
|
||||
if phone:
|
||||
query = query.filter(Client.phones.any(Phone.phone_number.ilike(f"%{phone}%")))
|
||||
if rolodex_id:
|
||||
query = query.filter(Client.rolodex_id.ilike(f"%{rolodex_id}%"))
|
||||
|
||||
# Sorting
|
||||
sort_dir_norm = (sort_dir or "").lower()
|
||||
if sort_dir_norm not in ("asc", "desc"):
|
||||
raise HTTPException(status_code=400, detail="Invalid sort_dir. Allowed: 'asc' or 'desc'")
|
||||
|
||||
allowed_sort = {
|
||||
"id": Client.id,
|
||||
"rolodex_id": Client.rolodex_id,
|
||||
"last_name": Client.last_name,
|
||||
"first_name": Client.first_name,
|
||||
"company": Client.company,
|
||||
"created_at": Client.created_at,
|
||||
}
|
||||
default_order = [Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last(), Client.id.asc()]
|
||||
query, applied_sort_by, applied_sort_dir = _apply_sorting(query, sort_by, sort_dir_norm, allowed_sort, default_order)
|
||||
|
||||
total: int = query.count()
|
||||
total_pages: int = (total + page_size - 1) // page_size if total > 0 else 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
clients = query.offset(offset).limit(page_size).all()
|
||||
|
||||
logger.info(
|
||||
"api_rolodex_list",
|
||||
query=q,
|
||||
phone=phone,
|
||||
rolodex_id=rolodex_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
sort_by=applied_sort_by,
|
||||
sort_dir=applied_sort_dir,
|
||||
)
|
||||
|
||||
items = [ClientOut.model_validate(c) for c in clients]
|
||||
return RolodexListResponse(
|
||||
items=items,
|
||||
pagination=Pagination(page=page, page_size=page_size, total=total, total_pages=total_pages),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/files", response_model=FilesListResponse)
|
||||
async def api_list_files(
|
||||
request: Request,
|
||||
q: str | None = Query(None, description="Search file no/description/client name/company"),
|
||||
status: str | None = Query(None, description="Case status: active or closed"),
|
||||
case_type: str | None = Query(None, description="Case type contains"),
|
||||
file_no: str | None = Query(None, description="File number contains"),
|
||||
client_rolodex_id: str | None = Query(None, description="Legacy client Id contains"),
|
||||
from_open_date: str | None = Query(None, description="Opened on/after YYYY-MM-DD"),
|
||||
to_open_date: str | None = Query(None, description="Opened on/before YYYY-MM-DD"),
|
||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Results per page"),
|
||||
sort_by: str | None = Query(None, description="Sort field: file_no, status, case_type, description, open_date, close_date, created_at, client_last_name, client_first_name, client_company"),
|
||||
sort_dir: str = Query("desc", description="Sort direction: asc or desc"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> FilesListResponse:
|
||||
"""Return paginated cases with simple filters as JSON."""
|
||||
user = get_current_user_from_session(request.session)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
query = (
|
||||
db.query(Case)
|
||||
.join(Client, Case.client_id == Client.id)
|
||||
.options(joinedload(Case.client))
|
||||
)
|
||||
|
||||
filters = []
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
filters.append(
|
||||
or_(
|
||||
Case.file_no.ilike(like),
|
||||
Case.description.ilike(like),
|
||||
Client.first_name.ilike(like),
|
||||
Client.last_name.ilike(like),
|
||||
Client.company.ilike(like),
|
||||
)
|
||||
)
|
||||
if status:
|
||||
filters.append(Case.status.ilike(f"%{status}%"))
|
||||
if case_type:
|
||||
filters.append(Case.case_type.ilike(f"%{case_type}%"))
|
||||
if file_no:
|
||||
filters.append(Case.file_no.ilike(f"%{file_no}%"))
|
||||
if client_rolodex_id:
|
||||
filters.append(Client.rolodex_id.ilike(f"%{client_rolodex_id}%"))
|
||||
if from_open_date:
|
||||
try:
|
||||
dt = datetime.strptime(from_open_date, "%Y-%m-%d")
|
||||
filters.append(Case.open_date >= dt)
|
||||
except ValueError:
|
||||
pass
|
||||
if to_open_date:
|
||||
try:
|
||||
dt = datetime.strptime(to_open_date, "%Y-%m-%d")
|
||||
filters.append(Case.open_date <= dt)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if filters:
|
||||
query = query.filter(and_(*filters))
|
||||
|
||||
# Sorting
|
||||
sort_dir_norm = (sort_dir or "").lower()
|
||||
if sort_dir_norm not in ("asc", "desc"):
|
||||
raise HTTPException(status_code=400, detail="Invalid sort_dir. Allowed: 'asc' or 'desc'")
|
||||
|
||||
allowed_sort = {
|
||||
"file_no": Case.file_no,
|
||||
"status": Case.status,
|
||||
"case_type": Case.case_type,
|
||||
"description": Case.description,
|
||||
"open_date": Case.open_date,
|
||||
"close_date": Case.close_date,
|
||||
"created_at": Case.created_at,
|
||||
"client_last_name": Client.last_name,
|
||||
"client_first_name": Client.first_name,
|
||||
"client_company": Client.company,
|
||||
"id": Case.id,
|
||||
}
|
||||
default_order = [Case.open_date.desc().nulls_last(), Case.created_at.desc()]
|
||||
query, applied_sort_by, applied_sort_dir = _apply_sorting(query, sort_by, sort_dir_norm, allowed_sort, default_order)
|
||||
|
||||
total: int = query.count()
|
||||
total_pages: int = (total + page_size - 1) // page_size if total > 0 else 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
cases = query.offset(offset).limit(page_size).all()
|
||||
|
||||
logger.info(
|
||||
"api_files_list",
|
||||
query=q,
|
||||
status=status,
|
||||
case_type=case_type,
|
||||
file_no=file_no,
|
||||
client_rolodex_id=client_rolodex_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
sort_by=applied_sort_by,
|
||||
sort_dir=applied_sort_dir,
|
||||
)
|
||||
|
||||
items = [CaseOut.model_validate(c) for c in cases]
|
||||
return FilesListResponse(
|
||||
items=items,
|
||||
pagination=Pagination(page=page, page_size=page_size, total=total, total_pages=total_pages),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/ledger", response_model=LedgerListResponse)
|
||||
async def api_list_ledger(
|
||||
request: Request,
|
||||
case_id: int | None = Query(None, description="Filter by case ID"),
|
||||
file_no: str | None = Query(None, description="Filter by case file number contains"),
|
||||
from_date: str | None = Query(None, description="On/after YYYY-MM-DD"),
|
||||
to_date: str | None = Query(None, description="On/before YYYY-MM-DD"),
|
||||
billed: str | None = Query(None, description="'Y' or 'N'"),
|
||||
t_code: str | None = Query(None, description="Transaction code contains"),
|
||||
t_type_l: str | None = Query(None, description="Legacy type flag (e.g., C/D)"),
|
||||
employee_number: str | None = Query(None, description="Employee number contains"),
|
||||
q: str | None = Query(None, description="Description contains"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
sort_by: str | None = Query(None, description="Sort field: transaction_date, item_no, id, amount, billed, t_code, t_type_l, employee_number, case_file_no, case_id"),
|
||||
sort_dir: str = Query("desc", description="Sort direction: asc or desc"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> LedgerListResponse:
|
||||
"""Return paginated ledger (transactions) with simple filters as JSON."""
|
||||
user = get_current_user_from_session(request.session)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
query = (
|
||||
db.query(Transaction)
|
||||
.join(Case, Transaction.case_id == Case.id)
|
||||
.options(joinedload(Transaction.case))
|
||||
)
|
||||
|
||||
filters = []
|
||||
if case_id is not None:
|
||||
filters.append(Transaction.case_id == case_id)
|
||||
if file_no:
|
||||
filters.append(Case.file_no.ilike(f"%{file_no}%"))
|
||||
if from_date:
|
||||
try:
|
||||
dt = datetime.strptime(from_date, "%Y-%m-%d")
|
||||
filters.append(Transaction.transaction_date >= dt)
|
||||
except ValueError:
|
||||
pass
|
||||
if to_date:
|
||||
try:
|
||||
dt = datetime.strptime(to_date, "%Y-%m-%d")
|
||||
filters.append(Transaction.transaction_date <= dt)
|
||||
except ValueError:
|
||||
pass
|
||||
if billed in ("Y", "N"):
|
||||
filters.append(Transaction.billed == billed)
|
||||
if t_code:
|
||||
filters.append(Transaction.t_code.ilike(f"%{t_code}%"))
|
||||
if t_type_l:
|
||||
filters.append(Transaction.t_type_l.ilike(f"%{t_type_l}%"))
|
||||
if employee_number:
|
||||
filters.append(Transaction.employee_number.ilike(f"%{employee_number}%"))
|
||||
if q:
|
||||
filters.append(Transaction.description.ilike(f"%{q}%"))
|
||||
|
||||
if filters:
|
||||
query = query.filter(and_(*filters))
|
||||
|
||||
# Sorting
|
||||
sort_dir_norm = (sort_dir or "").lower()
|
||||
if sort_dir_norm not in ("asc", "desc"):
|
||||
raise HTTPException(status_code=400, detail="Invalid sort_dir. Allowed: 'asc' or 'desc'")
|
||||
allowed_sort = {
|
||||
"transaction_date": Transaction.transaction_date,
|
||||
"item_no": Transaction.item_no,
|
||||
"id": Transaction.id,
|
||||
"amount": Transaction.amount,
|
||||
"billed": Transaction.billed,
|
||||
"t_code": Transaction.t_code,
|
||||
"t_type_l": Transaction.t_type_l,
|
||||
"employee_number": Transaction.employee_number,
|
||||
"case_file_no": Case.file_no,
|
||||
"case_id": Transaction.case_id,
|
||||
}
|
||||
default_order = [
|
||||
Transaction.transaction_date.desc().nulls_last(),
|
||||
Transaction.item_no.asc().nulls_last(),
|
||||
Transaction.id.desc(),
|
||||
]
|
||||
query, applied_sort_by, applied_sort_dir = _apply_sorting(query, sort_by, sort_dir_norm, allowed_sort, default_order)
|
||||
|
||||
total: int = query.count()
|
||||
total_pages: int = (total + page_size - 1) // page_size if total > 0 else 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
txns = query.offset(offset).limit(page_size).all()
|
||||
|
||||
logger.info(
|
||||
"api_ledger_list",
|
||||
case_id=case_id,
|
||||
file_no=file_no,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
billed=billed,
|
||||
t_code=t_code,
|
||||
t_type_l=t_type_l,
|
||||
employee_number=employee_number,
|
||||
q=q,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
sort_by=applied_sort_by,
|
||||
sort_dir=applied_sort_dir,
|
||||
)
|
||||
|
||||
items = [
|
||||
TransactionOut(
|
||||
id=t.id,
|
||||
case_id=t.case_id,
|
||||
case_file_no=t.case.file_no if t.case else None,
|
||||
transaction_date=t.transaction_date,
|
||||
item_no=t.item_no,
|
||||
amount=t.amount,
|
||||
billed=t.billed,
|
||||
t_code=t.t_code,
|
||||
t_type_l=t.t_type_l,
|
||||
quantity=t.quantity,
|
||||
rate=t.rate,
|
||||
description=t.description,
|
||||
employee_number=t.employee_number,
|
||||
)
|
||||
for t in txns
|
||||
]
|
||||
|
||||
return LedgerListResponse(
|
||||
items=items,
|
||||
pagination=Pagination(page=page, page_size=page_size, total=total, total_pages=total_pages),
|
||||
)
|
||||
|
||||
101
app/schemas.py
Normal file
101
app/schemas.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Pydantic schemas for API responses.
|
||||
|
||||
Defines output models for Clients, Phones, Cases, and Transactions, along with
|
||||
shared pagination envelopes for list endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class PhoneOut(BaseModel):
|
||||
id: int
|
||||
phone_type: Optional[str] = None
|
||||
phone_number: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ClientOut(BaseModel):
|
||||
id: int
|
||||
rolodex_id: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
company: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
zip_code: Optional[str] = None
|
||||
phones: Optional[List[PhoneOut]] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CaseClientOut(BaseModel):
|
||||
id: int
|
||||
rolodex_id: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
company: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CaseOut(BaseModel):
|
||||
id: int
|
||||
file_no: str
|
||||
status: Optional[str] = None
|
||||
case_type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
open_date: Optional[datetime] = None
|
||||
close_date: Optional[datetime] = None
|
||||
client: Optional[CaseClientOut] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TransactionOut(BaseModel):
|
||||
id: int
|
||||
case_id: int
|
||||
case_file_no: Optional[str] = None
|
||||
transaction_date: Optional[datetime] = None
|
||||
item_no: Optional[int] = None
|
||||
amount: Optional[float] = None
|
||||
billed: Optional[str] = None
|
||||
t_code: Optional[str] = None
|
||||
t_type_l: Optional[str] = None
|
||||
quantity: Optional[float] = None
|
||||
rate: Optional[float] = None
|
||||
description: Optional[str] = None
|
||||
employee_number: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class Pagination(BaseModel):
|
||||
page: int
|
||||
page_size: int
|
||||
total: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
class RolodexListResponse(BaseModel):
|
||||
items: List[ClientOut]
|
||||
pagination: Pagination
|
||||
|
||||
|
||||
class FilesListResponse(BaseModel):
|
||||
items: List[CaseOut]
|
||||
pagination: Pagination
|
||||
|
||||
|
||||
class LedgerListResponse(BaseModel):
|
||||
items: List[TransactionOut]
|
||||
pagination: Pagination
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
fastapi==0.104.1
|
||||
pydantic>=2.7,<3
|
||||
sqlalchemy==1.4.54
|
||||
alembic==1.12.1
|
||||
python-multipart==0.0.6
|
||||
|
||||
27
scripts/smoke.sh
Normal file
27
scripts/smoke.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:8000}"
|
||||
|
||||
echo "[1] Health check"
|
||||
curl -sf "$BASE_URL/health" >/dev/null || { echo "Health check failed"; exit 1; }
|
||||
|
||||
echo "[2] API unauthenticated should return 401 JSON"
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/rolodex")
|
||||
if [ "$code" != "401" ]; then
|
||||
echo "Expected 401, got $code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat << 'EX'
|
||||
Login via UI at /login to create a session cookie in your browser.
|
||||
For scripted tests, copy the cookie to cookies.txt and run examples:
|
||||
|
||||
curl -b cookies.txt "$BASE_URL/api/rolodex?page=1&page_size=2&sort_by=last_name&sort_dir=asc"
|
||||
curl -b cookies.txt "$BASE_URL/api/files?page=1&page_size=2&sort_by=open_date&sort_dir=desc"
|
||||
curl -b cookies.txt "$BASE_URL/api/ledger?page=1&page_size=2&sort_by=transaction_date&sort_dir=desc"
|
||||
EX
|
||||
|
||||
echo "Smoke tests completed."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user