diff --git a/backend/app/main.py b/backend/app/main.py index 50fb40f..238e622 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,7 +3,18 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.routers import health, news, senators +from app.routers import ( + budget, + carousel, + districts, + events, + finance, + health, + news, + pages, + senators, + staff, +) app = FastAPI( title="Senate API", @@ -24,6 +35,13 @@ app.include_router(health.router) app.include_router(news.router) app.include_router(senators.router) +app.include_router(carousel.router) +app.include_router(districts.router) +app.include_router(staff.router) +app.include_router(finance.router) +app.include_router(budget.router) +app.include_router(pages.router) +app.include_router(events.router) @app.get("/") diff --git a/backend/app/routers/budget.py b/backend/app/routers/budget.py new file mode 100644 index 0000000..bc7e3ef --- /dev/null +++ b/backend/app/routers/budget.py @@ -0,0 +1,73 @@ +"""Budget public API routes (TDD Section 4.5.2). + +GET /api/budget — hierarchical BudgetData; filterable by fiscal_year; defaults to most recent +""" + +import re +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.BudgetData import BudgetData +from app.schemas.budget import BudgetDataDTO + +router = APIRouter(prefix="/api/budget", tags=["budget"]) + + +def _fiscal_year_sort_key(fiscal_year: str) -> tuple[int, str]: + """Sort fiscal year labels by numeric suffix first, then raw value.""" + matches = re.findall(r"\d+", fiscal_year) + numeric_year = int(matches[-1]) if matches else -1 + return numeric_year, fiscal_year + + +def _build_tree( + row_id: int, + rows_by_id: dict[int, BudgetData], + children_map: dict[int, list[BudgetData]], +) -> BudgetDataDTO: + row = rows_by_id[row_id] + children = [ + _build_tree(child.id, rows_by_id, children_map) for child in children_map.get(row_id, []) + ] + return BudgetDataDTO( + id=row.id, + fiscal_year=row.fiscal_year, + category=row.category, + amount=float(row.amount), + description=row.description, + children=children, + ) + + +@router.get("", response_model=list[BudgetDataDTO]) +def list_budget( + fiscal_year: Optional[str] = Query(default=None, description="Fiscal year filter"), + db: Session = Depends(get_db), +): + if fiscal_year is None: + fiscal_years = [row[0] for row in db.query(BudgetData.fiscal_year).distinct().all()] + if not fiscal_years: + return [] + fiscal_year = max(fiscal_years, key=_fiscal_year_sort_key) + + rows = ( + db.query(BudgetData) + .filter(BudgetData.fiscal_year == fiscal_year) + .order_by(BudgetData.display_order) + .all() + ) + + rows_by_id = {row.id: row for row in rows} + children_map: dict[int, list[BudgetData]] = {row.id: [] for row in rows} + roots: list[BudgetData] = [] + + for row in rows: + if row.parent_category_id is None: + roots.append(row) + elif row.parent_category_id in children_map: + children_map[row.parent_category_id].append(row) + + return [_build_tree(r.id, rows_by_id, children_map) for r in roots] diff --git a/backend/app/routers/carousel.py b/backend/app/routers/carousel.py new file mode 100644 index 0000000..8297cf7 --- /dev/null +++ b/backend/app/routers/carousel.py @@ -0,0 +1,24 @@ +"""Carousel public API routes (TDD Section 4.5.2). + +GET /api/carousel — active slides only, ordered by display_order +""" + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.CarouselSlide import CarouselSlide +from app.schemas.carousel import CarouselSlideDTO + +router = APIRouter(prefix="/api/carousel", tags=["carousel"]) + + +@router.get("", response_model=list[CarouselSlideDTO]) +def list_carousel(db: Session = Depends(get_db)): + slides = ( + db.query(CarouselSlide) + .filter(CarouselSlide.is_active.is_(True)) + .order_by(CarouselSlide.display_order) + .all() + ) + return [CarouselSlideDTO.model_validate(s) for s in slides] diff --git a/backend/app/routers/districts.py b/backend/app/routers/districts.py new file mode 100644 index 0000000..3e56c1d --- /dev/null +++ b/backend/app/routers/districts.py @@ -0,0 +1,124 @@ +"""Districts public API routes (TDD Section 4.5.2). + +GET /api/districts — all districts with nested active senators +GET /api/districts/lookup — case-insensitive partial match on DistrictMapping.mapping_value +""" + +from typing import Any + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.cms import Committee, CommitteeMembership +from app.models.District import District, DistrictMapping +from app.models.Senator import Senator +from app.schemas.district import DistrictDTO + +router = APIRouter(prefix="/api/districts", tags=["districts"]) + + +def _senator_to_dict( + senator: Senator, + memberships: list[CommitteeMembership], + committees_by_id: dict[int, str], +) -> dict[str, Any]: + return { + "id": senator.id, + "first_name": senator.first_name, + "last_name": senator.last_name, + "email": senator.email, + "headshot_url": senator.headshot_url, + "district_id": senator.district, + "is_active": senator.is_active, + "session_number": senator.session_number, + "committees": [ + { + "committee_id": m.committee_id, + "committee_name": committees_by_id.get(m.committee_id, ""), + "role": m.role, + } + for m in memberships + ], + } + + +def _districts_to_dto(districts: list[District], db: Session) -> list[DistrictDTO]: + if not districts: + return [] + + district_ids = [district.id for district in districts] + senators = ( + db.query(Senator) + .filter(Senator.district.in_(district_ids), Senator.is_active.is_(True)) + .all() + ) + + senators_by_district: dict[int, list[Senator]] = {district.id: [] for district in districts} + for senator in senators: + senators_by_district.setdefault(senator.district, []).append(senator) + + senator_ids = [senator.id for senator in senators] + memberships: list[CommitteeMembership] = [] + if senator_ids: + memberships = ( + db.query(CommitteeMembership) + .filter(CommitteeMembership.senator_id.in_(senator_ids)) + .all() + ) + + memberships_by_senator: dict[int, list[CommitteeMembership]] = {senator_id: [] for senator_id in senator_ids} + for membership in memberships: + memberships_by_senator.setdefault(membership.senator_id, []).append(membership) + + committee_ids = list({membership.committee_id for membership in memberships}) + committees_by_id: dict[int, str] = {} + if committee_ids: + committees_by_id = { + committee.id: committee.name + for committee in db.query(Committee).filter(Committee.id.in_(committee_ids)).all() + } + + return [ + DistrictDTO.model_validate( + { + "id": district.id, + "district_name": district.district_name, + "description": district.description, + "senator": [ + _senator_to_dict( + senator, + memberships_by_senator.get(senator.id, []), + committees_by_id, + ) + for senator in senators_by_district.get(district.id, []) + ], + } + ) + for district in districts + ] + + +@router.get("/lookup", response_model=list[DistrictDTO]) +def lookup_district( + query: str = Query(..., description="Case-insensitive partial match on mapping value"), + db: Session = Depends(get_db), +): + pattern = f"%{query.lower()}%" + mappings = ( + db.query(DistrictMapping) + .filter(func.lower(DistrictMapping.mapping_value).like(pattern)) + .all() + ) + district_ids = list({m.district_id for m in mappings}) + if not district_ids: + return [] + districts = db.query(District).filter(District.id.in_(district_ids)).all() + return _districts_to_dto(districts, db) + + +@router.get("", response_model=list[DistrictDTO]) +def list_districts(db: Session = Depends(get_db)): + districts = db.query(District).all() + return _districts_to_dto(districts, db) diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py new file mode 100644 index 0000000..862abd0 --- /dev/null +++ b/backend/app/routers/events.py @@ -0,0 +1,36 @@ +"""Calendar events public API routes (TDD Section 4.5.2). + +GET /api/events — published events only; filterable by start_date, end_date, event_type +""" + +from datetime import date, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.CalendarEvent import CalendarEvent +from app.schemas.calendar_event import CalendarEventDTO + +router = APIRouter(prefix="/api/events", tags=["events"]) + + +@router.get("", response_model=list[CalendarEventDTO]) +def list_events( + start_date: Optional[date] = Query(default=None, description="Include events on or after this date"), + end_date: Optional[date] = Query(default=None, description="Include events on or before this date"), + event_type: Optional[str] = Query(default=None, description="Filter by event type"), + db: Session = Depends(get_db), +): + query = db.query(CalendarEvent).filter(CalendarEvent.is_published.is_(True)) + + if start_date is not None: + query = query.filter(CalendarEvent.start_datetime >= start_date) + if end_date is not None: + query = query.filter(CalendarEvent.start_datetime < end_date + timedelta(days=1)) + if event_type is not None: + query = query.filter(CalendarEvent.event_type == event_type) + + events = query.order_by(CalendarEvent.start_datetime).all() + return [CalendarEventDTO.model_validate(e) for e in events] diff --git a/backend/app/routers/finance.py b/backend/app/routers/finance.py new file mode 100644 index 0000000..2601ce7 --- /dev/null +++ b/backend/app/routers/finance.py @@ -0,0 +1,34 @@ +"""Finance hearings public API routes (TDD Section 4.5.2). + +GET /api/finance-hearings — FinanceHearingConfig with nested dates when active +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.FinanceHearingConfig import FinanceHearingConfig +from app.models.FinanceHearingDate import FinanceHearingDate +from app.schemas.finance import FinanceHearingConfigDTO, FinanceHearingDateDTO + +router = APIRouter(prefix="/api/finance-hearings", tags=["finance"]) + + +@router.get("", response_model=FinanceHearingConfigDTO) +def get_finance_hearings(db: Session = Depends(get_db)): + config = db.query(FinanceHearingConfig).first() + if config is None: + raise HTTPException(status_code=404, detail="Finance hearing configuration not found") + + dates: list[FinanceHearingDateDTO] = [] + if config.is_active: + dates = [ + FinanceHearingDateDTO.model_validate(d) for d in db.query(FinanceHearingDate).all() + ] + + return FinanceHearingConfigDTO( + is_active=config.is_active, + season_start=config.season_start, + season_end=config.season_end, + dates=dates, + ) diff --git a/backend/app/routers/pages.py b/backend/app/routers/pages.py new file mode 100644 index 0000000..04d1ea4 --- /dev/null +++ b/backend/app/routers/pages.py @@ -0,0 +1,21 @@ +"""Static pages public API routes (TDD Section 4.5.2). + +GET /api/pages/{slug} — static page content by slug, 404 if not found +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.cms import StaticPageContent +from app.schemas.static_page import StaticPageDTO + +router = APIRouter(prefix="/api/pages", tags=["pages"]) + + +@router.get("/{slug}", response_model=StaticPageDTO) +def get_page(slug: str, db: Session = Depends(get_db)): + page = db.query(StaticPageContent).filter(StaticPageContent.page_slug == slug).first() + if page is None: + raise HTTPException(status_code=404, detail="Page not found") + return StaticPageDTO.model_validate(page) diff --git a/backend/app/routers/staff.py b/backend/app/routers/staff.py new file mode 100644 index 0000000..13bbd76 --- /dev/null +++ b/backend/app/routers/staff.py @@ -0,0 +1,19 @@ +"""Staff public API routes (TDD Section 4.5.2). + +GET /api/staff — active staff ordered by display_order +""" + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.cms import Staff +from app.schemas.staff import StaffDTO + +router = APIRouter(prefix="/api/staff", tags=["staff"]) + + +@router.get("", response_model=list[StaffDTO]) +def list_staff(db: Session = Depends(get_db)): + staff = db.query(Staff).filter(Staff.is_active.is_(True)).order_by(Staff.display_order).all() + return [StaffDTO.model_validate(s) for s in staff] diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 28acb24..37c3a63 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -2,7 +2,7 @@ from .account import AccountDTO, CreateAccountDTO from .budget import BudgetDataDTO, CreateBudgetDataDTO -from .calendar_event import CreateCalendarEventDTO +from .calendar_event import CalendarEventDTO, CreateCalendarEventDTO from .carousel import CarouselSlideDTO, CreateCarouselSlideDTO from .committee import ( AssignCommitteeMemberDTO, @@ -41,6 +41,7 @@ "BudgetDataDTO", "CreateBudgetDataDTO", # Calendar Event + "CalendarEventDTO", "CreateCalendarEventDTO", # Carousel "CarouselSlideDTO", diff --git a/backend/app/schemas/calendar_event.py b/backend/app/schemas/calendar_event.py index 40617d4..d766330 100644 --- a/backend/app/schemas/calendar_event.py +++ b/backend/app/schemas/calendar_event.py @@ -1,8 +1,20 @@ -"""Calendar event schemas — input DTOs.""" +"""Calendar event schemas — input and output DTOs.""" from datetime import datetime -from pydantic import BaseModel, model_validator +from pydantic import BaseModel, ConfigDict, model_validator + + +class CalendarEventDTO(BaseModel): + id: int + title: str + description: str | None + start_datetime: datetime + end_datetime: datetime + location: str | None + event_type: str + + model_config = ConfigDict(from_attributes=True) class CreateCalendarEventDTO(BaseModel): diff --git a/backend/app/schemas/carousel.py b/backend/app/schemas/carousel.py index 9f038fb..f435d9c 100644 --- a/backend/app/schemas/carousel.py +++ b/backend/app/schemas/carousel.py @@ -6,8 +6,8 @@ class CarouselSlideDTO(BaseModel): id: int image_url: str - overlay_text: str - link_url: str + overlay_text: str | None + link_url: str | None display_order: int is_active: bool diff --git a/backend/tests/routes/conftest.py b/backend/tests/routes/conftest.py index 07c90e5..2fa5e1e 100644 --- a/backend/tests/routes/conftest.py +++ b/backend/tests/routes/conftest.py @@ -4,7 +4,7 @@ All GET-only endpoints are tested against seeded fixture data. """ -from datetime import datetime +from datetime import date, datetime, time import pytest from fastapi.testclient import TestClient @@ -18,8 +18,13 @@ from app.main import app from app.models import Admin, Senator from app.models.base import Base -from app.models.cms import Committee, CommitteeMembership, News -from app.models.District import District +from app.models.BudgetData import BudgetData +from app.models.CalendarEvent import CalendarEvent +from app.models.CarouselSlide import CarouselSlide +from app.models.cms import Committee, CommitteeMembership, News, Staff, StaticPageContent +from app.models.District import District, DistrictMapping +from app.models.FinanceHearingConfig import FinanceHearingConfig +from app.models.FinanceHearingDate import FinanceHearingDate # --------------------------------------------------------------------------- # Shared in-memory SQLite engine (module-scoped — created once per test module) @@ -43,9 +48,7 @@ def enforce_foreign_keys(dbapi_conn, _record): # Strip SQL Server-specific CHECK constraints before creating tables on SQLite for table in Base.metadata.tables.values(): - table.constraints = { - c for c in table.constraints if not isinstance(c, CheckConstraint) - } + table.constraints = {c for c in table.constraints if not isinstance(c, CheckConstraint)} Base.metadata.create_all(bind=engine) yield engine @@ -169,11 +172,197 @@ def seeded_engine(test_engine): db.flush() # --- Committee memberships --- + db.add_all( + [ + CommitteeMembership(senator_id=s1.id, committee_id=finance.id, role="Chair"), + CommitteeMembership(senator_id=s2.id, committee_id=finance.id, role="Member"), + CommitteeMembership(senator_id=s2.id, committee_id=judiciary.id, role="Member"), + ] + ) + db.flush() + + # --- Calendar events --- db.add_all([ - CommitteeMembership(senator_id=s1.id, committee_id=finance.id, role="Chair"), - CommitteeMembership(senator_id=s2.id, committee_id=finance.id, role="Member"), - CommitteeMembership(senator_id=s2.id, committee_id=judiciary.id, role="Member"), + CalendarEvent( + title="General Body Meeting", + description="Monthly meeting", + start_datetime=datetime(2026, 4, 1, 18, 0), + end_datetime=datetime(2026, 4, 1, 19, 30), + location="The Pit", + event_type="meeting", + is_published=True, + created_by=admin.id, + ), + CalendarEvent( + title="Finance Hearing", + description=None, + start_datetime=datetime(2026, 4, 15, 9, 0), + end_datetime=datetime(2026, 4, 15, 11, 0), + location="Union", + event_type="hearing", + is_published=True, + created_by=admin.id, + ), + CalendarEvent( + title="Draft Event", + description="Not published", + start_datetime=datetime(2026, 5, 1, 10, 0), + end_datetime=datetime(2026, 5, 1, 11, 0), + location=None, + event_type="meeting", + is_published=False, + created_by=admin.id, + ), ]) + db.flush() + + # --- Carousel slides --- + slide1 = CarouselSlide( + image_url="https://img.unc.edu/slide1.jpg", + overlay_text="Welcome to Senate", + link_url="https://unc.edu", + display_order=1, + is_active=True, + ) + slide2 = CarouselSlide( + image_url="https://img.unc.edu/slide2.jpg", + overlay_text=None, + link_url=None, + display_order=2, + is_active=True, + ) + slide3_inactive = CarouselSlide( + image_url="https://img.unc.edu/slide3.jpg", + overlay_text="Hidden", + link_url=None, + display_order=3, + is_active=False, + ) + db.add_all([slide1, slide2, slide3_inactive]) + db.flush() + + # --- District mappings --- + db.add_all( + [ + DistrictMapping(district_id=d1.id, mapping_value="on-campus"), + DistrictMapping(district_id=d2.id, mapping_value="off-campus"), + ] + ) + db.flush() + + # --- Staff --- + staff1 = Staff( + first_name="First", + last_name="Staff", + title="Director", + email="first@unc.edu", + display_order=1, + is_active=True, + ) + staff2 = Staff( + first_name="Second", + last_name="Staff", + title="Manager", + email="second@unc.edu", + display_order=2, + is_active=True, + ) + staff3_inactive = Staff( + first_name="Inactive", + last_name="Person", + title="Assistant", + email="inactive@unc.edu", + display_order=3, + is_active=False, + ) + db.add_all([staff1, staff2, staff3_inactive]) + db.flush() + + # --- Finance hearing config and dates --- + fhc = FinanceHearingConfig( + is_active=True, + season_start=date(2026, 1, 15), + season_end=date(2026, 5, 15), + updated_by=admin.id, + ) + db.add(fhc) + db.flush() + db.add_all( + [ + FinanceHearingDate( + hearing_date=date(2026, 2, 1), + hearing_time=time(9, 0), + location="The Pit", + description="Morning slot", + is_full=False, + ), + FinanceHearingDate( + hearing_date=date(2026, 2, 2), + hearing_time=time(14, 0), + location="Union", + description=None, + is_full=True, + ), + ] + ) + db.flush() + + # --- Budget data (FY2026 = most recent; FY2025 = older) --- + parent_budget = BudgetData( + fiscal_year="FY2026", + category="Operations", + amount=100000.00, + description="Operating budget", + parent_category_id=None, + display_order=1, + updated_by=admin.id, + ) + db.add(parent_budget) + db.flush() + db.add_all( + [ + BudgetData( + fiscal_year="FY2026", + category="Salaries", + amount=60000.00, + description=None, + parent_category_id=parent_budget.id, + display_order=2, + updated_by=admin.id, + ), + BudgetData( + fiscal_year="FY2026", + category="Supplies", + amount=40000.00, + description=None, + parent_category_id=parent_budget.id, + display_order=3, + updated_by=admin.id, + ), + BudgetData( + fiscal_year="FY2025", + category="Old Operations", + amount=90000.00, + description=None, + parent_category_id=None, + display_order=1, + updated_by=admin.id, + ), + ] + ) + db.flush() + + # --- Static page content --- + db.add( + StaticPageContent( + page_slug="powers-of-senate", + title="Powers of the Senate", + body="The senate has the power to...", + last_edited_by=admin.id, + ) + ) + db.flush() + db.commit() yield test_engine @@ -201,3 +390,15 @@ def _override_get_db(): with TestClient(app) as c: yield c app.dependency_overrides.pop(get_db, None) + + +@pytest.fixture(scope="function") +def db_session(seeded_engine): + """Direct DB session for targeted edge-case test setup.""" + TestSession = sessionmaker(bind=seeded_engine) + db = TestSession() + try: + yield db + db.commit() + finally: + db.close() diff --git a/backend/tests/routes/test_budget.py b/backend/tests/routes/test_budget.py new file mode 100644 index 0000000..2cd18ef --- /dev/null +++ b/backend/tests/routes/test_budget.py @@ -0,0 +1,87 @@ +"""Integration tests for GET /api/budget (TDD Section 4.5.2).""" + +from app.models import Admin +from app.models.BudgetData import BudgetData + + +class TestListBudget: + def test_returns_200(self, client): + assert client.get("/api/budget").status_code == 200 + + def test_returns_list(self, client): + data = client.get("/api/budget").json() + assert isinstance(data, list) + + def test_defaults_to_most_recent_fiscal_year(self, client): + """FY2026 is more recent than FY2025; default should return FY2026 rows only.""" + data = client.get("/api/budget").json() + assert len(data) >= 1 + assert all(item["fiscal_year"] == "FY2026" for item in data) + + def test_top_level_only_at_root(self, client): + """Only parent-level items (parent_category_id=NULL) appear at the root.""" + data = client.get("/api/budget").json() + # One parent seeded for FY2026 + assert len(data) == 1 + assert data[0]["category"] == "Operations" + + def test_hierarchical_children(self, client): + """'Operations' parent has 2 children: Salaries and Supplies.""" + data = client.get("/api/budget").json() + parent = data[0] + assert isinstance(parent["children"], list) + assert len(parent["children"]) == 2 + child_names = {c["category"] for c in parent["children"]} + assert "Salaries" in child_names + assert "Supplies" in child_names + + def test_children_have_empty_children_list(self, client): + """Leaf nodes must have an empty children list (not missing the field).""" + data = client.get("/api/budget").json() + for child in data[0]["children"]: + assert "children" in child + assert child["children"] == [] + + def test_filter_by_fiscal_year(self, client): + data = client.get("/api/budget?fiscal_year=FY2025").json() + assert len(data) == 1 + assert data[0]["fiscal_year"] == "FY2025" + assert data[0]["category"] == "Old Operations" + + def test_filter_older_year_excludes_newer(self, client): + data = client.get("/api/budget?fiscal_year=FY2025").json() + assert all(item["fiscal_year"] == "FY2025" for item in data) + + def test_nonexistent_fiscal_year_returns_empty(self, client): + data = client.get("/api/budget?fiscal_year=FY1900").json() + assert data == [] + + def test_budget_fields_present(self, client): + data = client.get("/api/budget").json() + item = data[0] + for field in ("id", "fiscal_year", "category", "amount", "description", "children"): + assert field in item, f"Field '{field}' missing from budget item" + + def test_amount_is_numeric(self, client): + data = client.get("/api/budget").json() + assert isinstance(data[0]["amount"], (int, float)) + + def test_default_year_not_lexicographic(self, client, db_session): + """Adding FY9 should not override FY2026 as the default year.""" + admin = db_session.query(Admin).first() + db_session.add( + BudgetData( + fiscal_year="FY9", + category="Legacy", + amount=1.00, + description=None, + parent_category_id=None, + display_order=99, + updated_by=admin.id, + ) + ) + db_session.commit() + + data = client.get("/api/budget").json() + assert len(data) >= 1 + assert all(item["fiscal_year"] == "FY2026" for item in data) diff --git a/backend/tests/routes/test_carousel.py b/backend/tests/routes/test_carousel.py new file mode 100644 index 0000000..ae4a5cf --- /dev/null +++ b/backend/tests/routes/test_carousel.py @@ -0,0 +1,39 @@ +"""Integration tests for GET /api/carousel (TDD Section 4.5.2).""" + + +class TestListCarousel: + def test_returns_200(self, client): + assert client.get("/api/carousel").status_code == 200 + + def test_returns_list(self, client): + data = client.get("/api/carousel").json() + assert isinstance(data, list) + + def test_only_active_slides(self, client): + """Inactive slide (slide3) must not appear; only 2 active slides seeded.""" + data = client.get("/api/carousel").json() + assert len(data) == 2 + assert all(s["is_active"] for s in data) + + def test_ordered_by_display_order(self, client): + data = client.get("/api/carousel").json() + orders = [s["display_order"] for s in data] + assert orders == sorted(orders) + + def test_slide_fields_present(self, client): + slide = client.get("/api/carousel").json()[0] + for field in ("id", "image_url", "overlay_text", "link_url", "display_order", "is_active"): + assert field in slide, f"Field '{field}' missing from carousel slide" + + def test_nullable_overlay_text_and_link_url(self, client): + """slide2 has null overlay_text and link_url — must be returned as null, not cause error.""" + data = client.get("/api/carousel").json() + nullish = [s for s in data if s["overlay_text"] is None] + assert len(nullish) == 1 + assert nullish[0]["link_url"] is None + + def test_first_slide_has_text_and_link(self, client): + data = client.get("/api/carousel").json() + first = next(s for s in data if s["display_order"] == 1) + assert first["overlay_text"] == "Welcome to Senate" + assert first["link_url"] == "https://unc.edu" diff --git a/backend/tests/routes/test_districts.py b/backend/tests/routes/test_districts.py new file mode 100644 index 0000000..048dfd2 --- /dev/null +++ b/backend/tests/routes/test_districts.py @@ -0,0 +1,85 @@ +"""Integration tests for GET /api/districts and GET /api/districts/lookup (TDD Section 4.5.2).""" + + +class TestListDistricts: + def test_returns_200(self, client): + assert client.get("/api/districts").status_code == 200 + + def test_returns_list(self, client): + data = client.get("/api/districts").json() + assert isinstance(data, list) + + def test_returns_all_districts(self, client): + data = client.get("/api/districts").json() + names = {d["district_name"] for d in data} + assert "On-Campus" in names + assert "Off-Campus" in names + + def test_district_fields_present(self, client): + district = client.get("/api/districts").json()[0] + for field in ("id", "district_name", "senator"): + assert field in district, f"Field '{field}' missing from district" + + def test_nested_senators_is_list(self, client): + data = client.get("/api/districts").json() + for district in data: + assert isinstance(district["senator"], list) + + def test_on_campus_has_active_senator(self, client): + """Alice Smith is active and assigned to On-Campus district.""" + data = client.get("/api/districts").json() + on_campus = next(d for d in data if d["district_name"] == "On-Campus") + names = {s["first_name"] for s in on_campus["senator"]} + assert "Alice" in names + + def test_inactive_senator_excluded_from_district(self, client): + """Carol Lee is inactive — must not appear nested under On-Campus.""" + data = client.get("/api/districts").json() + on_campus = next(d for d in data if d["district_name"] == "On-Campus") + names = {s["first_name"] for s in on_campus["senator"]} + assert "Carol" not in names + + def test_nested_senator_fields_present(self, client): + data = client.get("/api/districts").json() + on_campus = next(d for d in data if d["district_name"] == "On-Campus") + senator = on_campus["senator"][0] + for field in ("id", "first_name", "last_name", "email", "district_id", "committees"): + assert field in senator, f"Field '{field}' missing from nested senator" + + +class TestDistrictLookup: + def test_returns_200_with_match(self, client): + assert client.get("/api/districts/lookup?query=on-campus").status_code == 200 + + def test_returns_list(self, client): + data = client.get("/api/districts/lookup?query=on-campus").json() + assert isinstance(data, list) + + def test_exact_match(self, client): + data = client.get("/api/districts/lookup?query=on-campus").json() + assert len(data) == 1 + assert data[0]["district_name"] == "On-Campus" + + def test_partial_match(self, client): + """'campus' matches both on-campus and off-campus mappings.""" + data = client.get("/api/districts/lookup?query=campus").json() + assert len(data) == 2 + + def test_case_insensitive(self, client): + upper = client.get("/api/districts/lookup?query=ON-CAMPUS").json() + lower = client.get("/api/districts/lookup?query=on-campus").json() + assert len(upper) == len(lower) == 1 + + def test_no_match_returns_empty_list(self, client): + data = client.get("/api/districts/lookup?query=zzznomatch").json() + assert data == [] + + def test_missing_query_returns_422(self, client): + assert client.get("/api/districts/lookup").status_code == 422 + + def test_lookup_result_includes_nested_senators(self, client): + data = client.get("/api/districts/lookup?query=off-campus").json() + assert len(data) == 1 + assert isinstance(data[0]["senator"], list) + names = {s["first_name"] for s in data[0]["senator"]} + assert "Bob" in names diff --git a/backend/tests/routes/test_events.py b/backend/tests/routes/test_events.py new file mode 100644 index 0000000..7d54dc7 --- /dev/null +++ b/backend/tests/routes/test_events.py @@ -0,0 +1,86 @@ +"""Integration tests for GET /api/events (TDD Section 4.5.2).""" + + +class TestListEvents: + def test_returns_200(self, client): + assert client.get("/api/events").status_code == 200 + + def test_returns_list(self, client): + data = client.get("/api/events").json() + assert isinstance(data, list) + + def test_only_published_events(self, client): + """Draft event must not appear; only 2 published events seeded.""" + data = client.get("/api/events").json() + assert len(data) == 2 + assert all(e["title"] != "Draft Event" for e in data) + + def test_ordered_by_start_datetime(self, client): + data = client.get("/api/events").json() + starts = [e["start_datetime"] for e in data] + assert starts == sorted(starts) + + def test_event_fields_present(self, client): + event = client.get("/api/events").json()[0] + for field in ("id", "title", "description", "start_datetime", "end_datetime", "location", "event_type"): + assert field in event, f"Field '{field}' missing from event" + + def test_nullable_description(self, client): + """Finance Hearing event has null description.""" + data = client.get("/api/events").json() + null_desc = [e for e in data if e["description"] is None] + assert len(null_desc) == 1 + assert null_desc[0]["title"] == "Finance Hearing" + + # --- start_date filter --- + + def test_filter_start_date(self, client): + """start_date=2026-04-10 should exclude the April 1st meeting.""" + data = client.get("/api/events?start_date=2026-04-10").json() + assert len(data) == 1 + assert data[0]["title"] == "Finance Hearing" + + def test_filter_start_date_inclusive(self, client): + """Events on exactly start_date are included.""" + data = client.get("/api/events?start_date=2026-04-01").json() + assert len(data) == 2 + + # --- end_date filter --- + + def test_filter_end_date(self, client): + """end_date=2026-04-05 should only return the April 1st meeting.""" + data = client.get("/api/events?end_date=2026-04-05").json() + assert len(data) == 1 + assert data[0]["title"] == "General Body Meeting" + + def test_filter_end_date_inclusive(self, client): + """Events on exactly end_date are included.""" + data = client.get("/api/events?end_date=2026-04-15").json() + assert len(data) == 2 + + # --- event_type filter --- + + def test_filter_by_event_type(self, client): + data = client.get("/api/events?event_type=hearing").json() + assert len(data) == 1 + assert data[0]["title"] == "Finance Hearing" + + def test_filter_by_event_type_meeting(self, client): + data = client.get("/api/events?event_type=meeting").json() + assert len(data) == 1 + assert data[0]["title"] == "General Body Meeting" + + def test_filter_nonexistent_type_returns_empty(self, client): + data = client.get("/api/events?event_type=zzznotype").json() + assert data == [] + + # --- combined filters --- + + def test_combined_start_and_type_filter(self, client): + data = client.get("/api/events?start_date=2026-04-10&event_type=hearing").json() + assert len(data) == 1 + assert data[0]["title"] == "Finance Hearing" + + def test_date_range_no_results(self, client): + data = client.get("/api/events?start_date=2026-06-01&end_date=2026-06-30").json() + assert data == [] diff --git a/backend/tests/routes/test_finance.py b/backend/tests/routes/test_finance.py new file mode 100644 index 0000000..9b576ed --- /dev/null +++ b/backend/tests/routes/test_finance.py @@ -0,0 +1,55 @@ +"""Integration tests for GET /api/finance-hearings (TDD Section 4.5.2).""" + +from app.models.FinanceHearingConfig import FinanceHearingConfig + + +class TestGetFinanceHearings: + def test_returns_200(self, client): + assert client.get("/api/finance-hearings").status_code == 200 + + def test_returns_config_fields(self, client): + data = client.get("/api/finance-hearings").json() + for field in ("is_active", "season_start", "season_end", "dates"): + assert field in data, f"Field '{field}' missing from finance hearing config" + + def test_active_config(self, client): + data = client.get("/api/finance-hearings").json() + assert data["is_active"] is True + + def test_season_dates_present(self, client): + data = client.get("/api/finance-hearings").json() + assert data["season_start"] == "2026-01-15" + assert data["season_end"] == "2026-05-15" + + def test_active_config_includes_dates(self, client): + """Active config should include all 2 seeded hearing dates.""" + data = client.get("/api/finance-hearings").json() + assert isinstance(data["dates"], list) + assert len(data["dates"]) == 2 + + def test_date_fields_present(self, client): + data = client.get("/api/finance-hearings").json() + date_item = data["dates"][0] + for field in ("id", "hearing_date", "hearing_time", "location", "description", "is_full"): + assert field in date_item, f"Field '{field}' missing from hearing date" + + def test_nullable_description(self, client): + """Second hearing date has null description — must be returned as null.""" + data = client.get("/api/finance-hearings").json() + has_null_desc = any(d["description"] is None for d in data["dates"]) + assert has_null_desc + + def test_is_full_flag(self, client): + """Second hearing date is marked full.""" + data = client.get("/api/finance-hearings").json() + full_dates = [d for d in data["dates"] if d["is_full"]] + assert len(full_dates) == 1 + + def test_inactive_config_returns_empty_dates(self, client, db_session): + config = db_session.query(FinanceHearingConfig).first() + config.is_active = False + db_session.commit() + + data = client.get("/api/finance-hearings").json() + assert data["is_active"] is False + assert data["dates"] == [] diff --git a/backend/tests/routes/test_pages.py b/backend/tests/routes/test_pages.py new file mode 100644 index 0000000..e424762 --- /dev/null +++ b/backend/tests/routes/test_pages.py @@ -0,0 +1,27 @@ +"""Integration tests for GET /api/pages/:slug (TDD Section 4.5.2).""" + + +class TestGetPage: + def test_returns_200_for_existing_slug(self, client): + assert client.get("/api/pages/powers-of-senate").status_code == 200 + + def test_returns_correct_page(self, client): + data = client.get("/api/pages/powers-of-senate").json() + assert data["page_slug"] == "powers-of-senate" + assert data["title"] == "Powers of the Senate" + + def test_page_fields_present(self, client): + data = client.get("/api/pages/powers-of-senate").json() + for field in ("id", "page_slug", "title", "body", "updated_at"): + assert field in data, f"Field '{field}' missing from page" + + def test_body_not_empty(self, client): + data = client.get("/api/pages/powers-of-senate").json() + assert len(data["body"]) > 0 + + def test_404_for_nonexistent_slug(self, client): + assert client.get("/api/pages/this-page-does-not-exist").status_code == 404 + + def test_404_response_has_detail(self, client): + data = client.get("/api/pages/this-page-does-not-exist").json() + assert "detail" in data diff --git a/backend/tests/routes/test_staff.py b/backend/tests/routes/test_staff.py new file mode 100644 index 0000000..091424e --- /dev/null +++ b/backend/tests/routes/test_staff.py @@ -0,0 +1,35 @@ +"""Integration tests for GET /api/staff (TDD Section 4.5.2).""" + + +class TestListStaff: + def test_returns_200(self, client): + assert client.get("/api/staff").status_code == 200 + + def test_returns_list(self, client): + data = client.get("/api/staff").json() + assert isinstance(data, list) + + def test_only_active_staff_returned(self, client): + """3 staff seeded (2 active, 1 inactive); only 2 should be returned.""" + data = client.get("/api/staff").json() + assert len(data) == 2 + + def test_inactive_staff_excluded(self, client): + data = client.get("/api/staff").json() + names = {s["first_name"] for s in data} + assert "Inactive" not in names + + def test_ordered_by_display_order(self, client): + data = client.get("/api/staff").json() + assert data[0]["first_name"] == "First" + assert data[1]["first_name"] == "Second" + + def test_staff_fields_present(self, client): + staff = client.get("/api/staff").json()[0] + for field in ("id", "first_name", "last_name", "title", "email", "photo_url"): + assert field in staff, f"Field '{field}' missing from staff" + + def test_photo_url_nullable(self, client): + """Staff seeded without photo_url; it should default to null.""" + data = client.get("/api/staff").json() + assert all(s["photo_url"] is None for s in data)