diff --git a/modules/module-01/REFLECTION.md b/modules/module-01/REFLECTION.md index ea9859e7..31ac9f8f 100644 --- a/modules/module-01/REFLECTION.md +++ b/modules/module-01/REFLECTION.md @@ -24,7 +24,7 @@ Think about it from three angles: the developer who has to change code, the team > _Your answer:_ ---- +---Splitting the monolith into separate services makes the system easier to manage and maintain. For developers, changing one service becomes safer because it does not risk breaking unrelated parts of the application. For deployment teams, each service can be updated independently without redeploying the entire system. For users, failures are more isolated, meaning one broken service does not necessarily take down the whole platform ## 2. Your choice @@ -36,7 +36,7 @@ What would break, slow down, or become harder to manage if you merged those two > _Your answer:_ ---- +---We decided to separate the activity-service from the logging-service. The activity-service is responsible for gameplay actions, while the logging-service only stores logs and audit records. If both services were merged together, gameplay performance could be affected whenever logging becomes slow or unavailable. Keeping them separate also makes the system easier to scale and maintain because logging can grow independently from gameplay activity. ## 3. The tradeoff @@ -48,6 +48,6 @@ No need to solve it: just name it honestly. This is exactly the tension the rest > _Your answer:_ ---- +---Communication between components was simpler in the monolith because everything was inside the same application and database. In a distributed system, services must communicate over the network using REST APIs or async events, which adds complexity, latency, and more points of failure. _Keep this file. You will refer back to it during the oral presentation._ diff --git a/modules/module-01/exercise.md b/modules/module-01/exercise.md index d5bf307c..c3d466b8 100644 --- a/modules/module-01/exercise.md +++ b/modules/module-01/exercise.md @@ -25,12 +25,14 @@ Read these two documents before doing anything else: A bounded context is a part of the system that has a clear responsibility and owns its data exclusively. No other service should reach into its database. For each bounded context you identify, fill in the table: +| Bounded Context | Responsibilities | Owned Entities | Team | +| --------------- | -------------------------------------------------------- | -------------------------- | -------------- | +| Identity | Manages who users are, handles registration and profiles | User, Session | Platform | +| Game Library | Manages the game catalog and game details | Game, Genre | Content | +| Activity | Tracks player activity and gameplay events | Activity, MatchHistory | Analytics | +| Notification | Sends emails, alerts, and real-time notifications | Notification, QueueMessage | Communication | +| Logging | Stores system logs and audit events | LogEntry, AuditRecord | Infrastructure | -| Bounded Context | Responsibilities | Owned Entities | Team | -| --------------- | -------------------------------------------------------- | -------------- | ----------- | -| Identity | Manages who users are, handles registration and profiles | User, Session | Platform | -| Game Library | _(fill in)_ | _(fill in)_ | _(fill in)_ | -| _(add more)_ | | | | There is no single correct answer: what matters is that you can justify each row. @@ -56,7 +58,21 @@ Payload: { activity_id, user_id, action, game_id, timestamp } Focus on the flows that feel non-obvious. You do not need to document every possible pair. ---- +---Service Contracts +gateway → identity-service +Trigger: user logs in +Protocol: REST +Payload: { email, password } + +activity-service → logging-service +Trigger: gameplay activity recorded +Protocol: RabbitMQ event (async) +Payload: { activity_id, user_id, action, timestamp } + +game-library-service → notification-service +Trigger: new game added +Protocol: RabbitMQ event (async) +Payload: { game_id, title, release_date } ## Task 3 — Draw the service map _(~20 min)_ @@ -70,13 +86,45 @@ Draw the full GameHub service map: This can be a sketch on paper, a whiteboard photo, or ASCII art committed to your branch. --- + +-------------+ + | Gateway | + +-------------+ + / | \ + / | \ + v v v + + +-------------+ +-------------+ + | Identity | | Game Library| + | Service | | Service | + +-------------+ +-------------+ + | | + | | + v v + + +-------------+ - - - - - - - - - + | Activity | - - > Notification | + | Service | (RabbitMQ) | + +-------------+ - - - - - - - - - + + | + | + v + + +-------------+ + | Logging | + | Service | + +-------------+ ## Discussion _(~15 min)_ Three questions to discuss as a team before you leave: 1. Why does `notification-service` use Node.js instead of Python like the rest? What does that tell you about microservices and technology choices? +--Node.js is well suited for real-time communication and event-driven systems. +This shows that microservices can use different technologies depending on the needs of each service. + 2. What is the risk of `activity-service` calling `logging-service` synchronously — why might you prefer an async event instead? + 3. Why does `logging-service` need a GDPR consent check before recording any activity? You do not need to write these answers down — they are warm-up for your REFLECTION.md. diff --git a/services/game-service/README.md b/services/game-service/README.md index da9ab229..dea960b8 100644 --- a/services/game-service/README.md +++ b/services/game-service/README.md @@ -1,26 +1,26 @@ -# game-service — your implementation +# user-service — reference implementation -This is the service you build in Module 2. Use `services/user-service/README.md` as your working reference — the structure is identical, only the entity changes. +Read this before building `game-service`. Every file below is annotated to explain its role. The structure you see here is the one you must replicate. --- ## Folder structure ``` -game-service/ +user-service/ ├── app/ │ ├── __init__.py # empty, makes app a package │ ├── main.py # FastAPI app init, mounts the router │ ├── database.py # engine + session factory -│ ├── models.py # SQLAlchemy ORM model (Game) -│ ├── schemas.py # Pydantic DTOs (GameCreate, GameOut) +│ ├── models.py # SQLAlchemy ORM model (User) +│ ├── schemas.py # Pydantic DTOs (UserCreate, UserOut) │ ├── repository.py # raw DB queries — no business logic here │ ├── service.py # business logic — calls repository │ └── routes.py # FastAPI router + endpoint handlers ├── alembic/ │ └── versions/ # auto-generated migration files go here ├── tests/ -│ └── test_games.py +│ └── test_users.py ├── alembic.ini ├── requirements.txt └── .env.example @@ -32,23 +32,22 @@ game-service/ ### `app/models.py` — ORM model -Defines the `games` table. This is the only file that knows about columns. +Defines the `users` table. This is the only file that knows about columns. ```python -from sqlalchemy import Column, String, Integer, DateTime +from sqlalchemy import Column, String, Boolean, DateTime from datetime import datetime, timezone import uuid from app.database import Base -class Game(Base): - __tablename__ = "games" +class User(Base): + __tablename__ = "users" id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - title = Column(String, nullable=False) - genre = Column(String, nullable=False) - platform = Column(String, nullable=False) - release_year = Column(Integer, nullable=True) - cover_url = Column(String, nullable=True) + username = Column(String, unique=True, nullable=False) + email = Column(String, unique=True, nullable=False) + hashed_password = Column(String, nullable=False) + is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) ``` @@ -56,33 +55,29 @@ class Game(Base): ### `app/schemas.py` — Pydantic DTOs -`GameCreate` = what comes **in**. `GameOut` = what goes **out**. Keep them separate. +Two schemas: one for input, one for output. Keep them separate — the response shape is not always the same as the request shape. ```python from pydantic import BaseModel from datetime import datetime -class GameCreate(BaseModel): - title: str - genre: str - platform: str - release_year: int | None = None - cover_url: str | None = None +class UserCreate(BaseModel): + username: str + email: str + password: str # plain-text on the way in — hash it in the service layer -class GameOut(BaseModel): +class UserOut(BaseModel): id: str - title: str - genre: str - platform: str - release_year: int | None - cover_url: str | None + username: str + email: str + is_active: bool created_at: datetime model_config = {"from_attributes": True} -class GameList(BaseModel): +class UserList(BaseModel): """Paginated envelope — all list endpoints return this shape.""" - items: list[GameOut] + items: list[UserOut] total: int limit: int offset: int @@ -99,7 +94,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, DeclarativeBase import os -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./games.db") +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./users.db") engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -123,21 +118,27 @@ Functions here take a `Session` and return ORM objects. No HTTP, no business rul ```python from sqlalchemy.orm import Session -from app.models import Game -from app.schemas import GameCreate - -def create_game(db: Session, data: GameCreate) -> Game: - ... - -def get_game(db: Session, game_id: str) -> Game | None: - ... - -def list_games(db: Session, limit: int = 20, offset: int = 0) -> tuple[list[Game], int]: - ... - -def search_games(db: Session, q: str, limit: int = 20, offset: int = 0) -> tuple[list[Game], int]: - # hint: use .filter(Game.title.ilike(f"%{q}%")) - ... +from app.models import User +from app.schemas import UserCreate + +def create_user(db: Session, data: UserCreate, hashed_password: str) -> User: + user = User( + username=data.username, + email=data.email, + hashed_password=hashed_password, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + +def get_user(db: Session, user_id: str) -> User | None: + return db.query(User).filter(User.id == user_id).first() + +def list_users(db: Session, limit: int = 20, offset: int = 0) -> tuple[list[User], int]: + total = db.query(User).count() + users = db.query(User).offset(offset).limit(limit).all() + return users, total ``` --- @@ -149,53 +150,63 @@ Calls the repository and returns Pydantic schemas (not raw ORM objects) to the r ```python from sqlalchemy.orm import Session from app import repository -from app.schemas import GameCreate, GameOut, GameList - -def add_game(db: Session, data: GameCreate) -> GameOut: - ... - -def fetch_game(db: Session, game_id: str) -> GameOut: - # raise ValueError if not found — routes.py turns it into a 404 - ... - -def fetch_all_games(db: Session, limit: int = 20, offset: int = 0) -> GameList: - ... - -def find_games(db: Session, q: str, limit: int = 20, offset: int = 0) -> GameList: - ... +from app.schemas import UserCreate, UserOut, UserList + +def _hash_password(plain: str) -> str: + # for now a placeholder — swap for passlib in Module 6 + return plain + "_hashed" + +def add_user(db: Session, data: UserCreate) -> UserOut: + hashed = _hash_password(data.password) + user = repository.create_user(db, data, hashed) + return UserOut.model_validate(user) + +def fetch_user(db: Session, user_id: str) -> UserOut: + user = repository.get_user(db, user_id) + if user is None: + raise ValueError(f"User {user_id} not found") + return UserOut.model_validate(user) + +def fetch_all_users(db: Session, limit: int = 20, offset: int = 0) -> UserList: + users, total = repository.list_users(db, limit=limit, offset=offset) + return UserList( + items=[UserOut.model_validate(u) for u in users], + total=total, + limit=limit, + offset=offset, + ) ``` +The `ValueError` raised here gets caught in `routes.py` and turned into an HTTP 404. + --- ### `app/routes.py` — HTTP layer One function per endpoint. Routes only call service functions — never the repository directly. -> **Order matters**: `/search` must be declared **before** `/{game_id}`, otherwise FastAPI will try to match the word "search" as an integer ID and return a 422. - ```python from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db from app import service, schemas -router = APIRouter(prefix="/v1/games", tags=["games"]) - -@router.post("/", response_model=schemas.GameOut, status_code=201) -def create_game(data: schemas.GameCreate, db: Session = Depends(get_db)): - ... +router = APIRouter(prefix="/v1/users", tags=["users"]) -@router.get("/", response_model=schemas.GameList) -def list_games(limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): - ... +@router.post("/", response_model=schemas.UserOut, status_code=201) +def create_user(data: schemas.UserCreate, db: Session = Depends(get_db)): + return service.add_user(db, data) -@router.get("/search", response_model=schemas.GameList) -def search_games(q: str, limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): - ... +@router.get("/", response_model=schemas.UserList) +def list_users(limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): + return service.fetch_all_users(db, limit=limit, offset=offset) -@router.get("/{game_id}", response_model=schemas.GameOut) -def get_game(game_id: str, db: Session = Depends(get_db)): - ... +@router.get("/{user_id}", response_model=schemas.UserOut) +def get_user(user_id: str, db: Session = Depends(get_db)): + try: + return service.fetch_user(db, user_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) ``` --- @@ -206,7 +217,7 @@ def get_game(game_id: str, db: Session = Depends(get_db)): from fastapi import FastAPI from app.routes import router -app = FastAPI(title="game-service") +app = FastAPI(title="user-service") app.include_router(router) ``` @@ -229,7 +240,7 @@ aiosqlite ### `.env.example` ``` -DATABASE_URL=sqlite:///./games.db +DATABASE_URL=sqlite:///./users.db ``` Copy to `.env` before running. Never commit `.env`. diff --git a/services/game-service/app/database.py b/services/game-service/app/database.py index 56dfd44d..176598c8 100644 --- a/services/game-service/app/database.py +++ b/services/game-service/app/database.py @@ -1,10 +1,30 @@ # Infrastructure layer — database connection. # -# Replicate the same structure as user-service/app/database.py. -# The only difference: the default DATABASE_URL points to games.db. +# This file is responsible for: +# - Creating the SQLAlchemy engine from DATABASE_URL (read from .env) +# - Defining the declarative Base that all ORM models inherit from +# - Providing get_db(), a FastAPI dependency that opens a session per request +# and closes it when the request is done (using yield) # -# This file should provide: -# - engine — SQLAlchemy engine built from DATABASE_URL -# - SessionLocal — session factory bound to the engine -# - Base — DeclarativeBase that all ORM models inherit from -# - get_db() — FastAPI dependency: yields a session, closes it after the request +# Nothing in this file knows about Users, Games, or any business concept. +# It is pure infrastructure. +# +# See the README for the full implementation. +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +DATABASE_URL = "sqlite:///./games.db" + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + +Base = declarative_base() \ No newline at end of file diff --git a/services/game-service/app/main.py b/services/game-service/app/main.py index e629900e..06226a5e 100644 --- a/services/game-service/app/main.py +++ b/services/game-service/app/main.py @@ -1,9 +1,20 @@ # Entry point — FastAPI application. # -# Create the FastAPI app instance and register the router from app.routes. +# This file creates the FastAPI app instance and registers the router. # Keep it minimal: no business logic, no endpoints defined here. # # To run the service locally: -# uvicorn app.main:app --reload --port 8002 +# uvicorn app.main:app --reload --port 8001 # -# Then open: http://localhost:8002/docs +# Then open: http://localhost:8001/docs +# +# See the README for the full implementation. +from fastapi import FastAPI +from app.routes import router +from app.database import Base, engine + +Base.metadata.create_all(bind=engine) + +app = FastAPI() + +app.include_router(router) \ No newline at end of file diff --git a/services/game-service/app/models.py b/services/game-service/app/models.py index 52eb2756..8baf0df1 100644 --- a/services/game-service/app/models.py +++ b/services/game-service/app/models.py @@ -1,14 +1,25 @@ # Infrastructure layer — ORM model. # -# Define the `games` table. This is the only file that knows about columns. -# -# The Game model should have these columns: -# - id String, primary key, UUID generated by default -# - title String, not nullable -# - genre String, not nullable -# - platform String, not nullable -# - release_year Integer, nullable -# - cover_url String, nullable -# - created_at DateTime, defaults to now (UTC) -# -# Import Base from app.database — do not redefine it here. +# This is the only file that defines the shape of the `users` table. +# It maps Python attributes to database columns using SQLAlchemy. +# +# This file should: +# - Import Base from app.database +# - Define a User class with columns: id, username, email, +# hashed_password, is_active, created_at +# +# Rule: no business logic here. This file only describes data structure. +# +# See the README for the full implementation. +from sqlalchemy import Column, Integer, String +from app.database import Base + + +class Game(Base): + __tablename__ = "games" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + genre = Column(String, nullable=False) + platform = Column(String, nullable=False) + cover_url = Column(String, nullable=False) \ No newline at end of file diff --git a/services/game-service/app/repository.py b/services/game-service/app/repository.py index 8120c970..9a313a14 100644 --- a/services/game-service/app/repository.py +++ b/services/game-service/app/repository.py @@ -1,10 +1,38 @@ # Infrastructure layer — raw database queries. # -# Implement these four functions. Each takes `db: Session` as its first argument. -# No business logic here — only ORM queries. -# -# - create_game(db, data) -> Game -# - get_game(db, game_id) -> Game | None -# - list_games(db, limit, offset) -> tuple[list[Game], int] -# - search_games(db, q, limit, offset) -> tuple[list[Game], int] -# Hint: filter by title using .ilike(f"%{q}%") for case-insensitive search +# Functions here take a SQLAlchemy Session and return ORM objects. +# This is the only layer allowed to write SQL / ORM queries. +# +# Rules: +# - No HTTP knowledge here (no Request, no HTTPException) +# - No business rules here (no password hashing, no validation logic) +# - Every function receives `db: Session` as its first argument +# +# This file should implement: +# - create_user(db, data, hashed_password) -> User +# - get_user(db, user_id) -> User | None +# - list_users(db, limit, offset) -> tuple[list[User], int] +# +# See the README for the full implementation. +from sqlalchemy.orm import Session +from app.models import Game + + +def create_game(db: Session, game_data): + game = Game(**game_data.dict()) + db.add(game) + db.commit() + db.refresh(game) + return game + + +def get_games(db: Session): + return db.query(Game).all() + + +def get_game_by_id(db: Session, game_id: int): + return db.query(Game).filter(Game.id == game_id).first() + + +def search_games(db: Session, q: str): + return db.query(Game).filter(Game.title.ilike(f"%{q}%")).all() \ No newline at end of file diff --git a/services/game-service/app/routes.py b/services/game-service/app/routes.py index db173fd2..41d66d81 100644 --- a/services/game-service/app/routes.py +++ b/services/game-service/app/routes.py @@ -1,11 +1,52 @@ # Interface layer — HTTP endpoints. # -# Define a router with prefix="/v1/games" and implement these endpoints: -# - POST /v1/games/ -> create a game (201) -# - GET /v1/games/ -> list games (limit/offset pagination) -# - GET /v1/games/search -> search games by title (?q=...) -# - GET /v1/games/{game_id} -> get one game by ID (404 if not found) -# -# IMPORTANT: declare /search BEFORE /{game_id} in your router. -# If /{game_id} comes first, FastAPI will try to match "search" as an ID -# and return a 422 Unprocessable Entity error. +# This file defines the FastAPI router and maps HTTP verbs + paths to +# service function calls. It is the only layer that knows about HTTP. +# +# Rules: +# - Never call repository functions directly — always go through service +# - Catch ValueError from the service layer and raise HTTPException instead +# - Use Depends(get_db) to inject the database session +# +# This file should expose: +# - POST /v1/users/ -> create a user +# - GET /v1/users/ -> list users (with limit/offset pagination) +# - GET /v1/users/{user_id} -> get one user by ID (404 if not found) +# +# See the README for the full implementation. +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.database import SessionLocal +from app.schemas import GameCreate, GameResponse +from app import service + +router = APIRouter(prefix="/v1/games", tags=["games"]) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +@router.post("", response_model=GameResponse) +def create_game(game: GameCreate, db: Session = Depends(get_db)): + return service.create_game(db, game) + + +@router.get("", response_model=list[GameResponse]) +def get_games(db: Session = Depends(get_db)): + return service.get_games(db) + + +@router.get("/{game_id}", response_model=GameResponse) +def get_game(game_id: int, db: Session = Depends(get_db)): + return service.get_game_by_id(db, game_id) + + +@router.get("/search/", response_model=list[GameResponse]) +def search_games(q: str, db: Session = Depends(get_db)): + return service.search_games(db, q) \ No newline at end of file diff --git a/services/game-service/app/schemas.py b/services/game-service/app/schemas.py index ca6aca4e..ad4dfddd 100644 --- a/services/game-service/app/schemas.py +++ b/services/game-service/app/schemas.py @@ -1,10 +1,30 @@ -# Application layer — Pydantic DTOs. +# Application layer — Pydantic DTOs (Data Transfer Objects). # -# Define the shapes of data coming IN and going OUT of the API. +# This file defines the shapes of data coming IN and going OUT of the API. +# It is separate from models.py on purpose: the API shape is not always +# the same as the database shape (e.g. password comes in, never goes out). # # This file should define: -# - GameCreate — fields accepted when creating a game -# (title, genre, platform required; release_year and cover_url optional) -# - GameOut — fields returned to the caller (includes id and created_at) -# add model_config = {"from_attributes": True} -# - GameList — paginated envelope: { items, total, limit, offset } +# - UserCreate — fields accepted when creating a user (includes plain password) +# - UserOut — fields returned to the caller (no password, ever) +# - UserList — paginated envelope: { items, total, limit, offset } +# +# Use model_config = {"from_attributes": True} on UserOut so Pydantic can +# read directly from SQLAlchemy ORM objects. +# +# See the README for the full implementation. +from pydantic import BaseModel + + +class GameCreate(BaseModel): + title: str + genre: str + platform: str + cover_url: str + + +class GameResponse(GameCreate): + id: int + + class Config: + from_attributes = True \ No newline at end of file diff --git a/services/game-service/app/service.py b/services/game-service/app/service.py index 78c4451a..a1b21397 100644 --- a/services/game-service/app/service.py +++ b/services/game-service/app/service.py @@ -1,10 +1,38 @@ # Application layer — business logic. # -# Calls repository functions and returns Pydantic schemas (not raw ORM objects). -# Raises ValueError when a game is not found — routes.py turns it into a 404. -# -# Implement these four functions: -# - add_game(db, data) -> GameOut -# - fetch_game(db, game_id) -> GameOut (raises ValueError if not found) -# - fetch_all_games(db, limit, offset) -> GameList -# - find_games(db, q, limit, offset) -> GameList (delegates to search_games in repository) +# This is where decisions live: hashing passwords, raising errors when a user +# is not found, converting ORM objects into Pydantic schemas before returning. +# +# Rules: +# - Only calls repository functions — never queries the DB directly +# - Returns Pydantic schemas (UserOut, UserList), not raw ORM objects +# - Raises ValueError for business errors (routes.py turns them into HTTP errors) +# +# This file should implement: +# - add_user(db, data) -> UserOut +# - fetch_user(db, user_id) -> UserOut (raises ValueError if not found) +# - fetch_all_users(db, limit, offset) -> UserList +# +# Note: _hash_password is a placeholder for now — it will be replaced +# with passlib in Module 6. +# +# See the README for the full implementation. +from sqlalchemy.orm import Session +from app import repository +from app.schemas import GameCreate + + +def create_game(db: Session, game: GameCreate): + return repository.create_game(db, game) + + +def get_games(db: Session): + return repository.get_games(db) + + +def get_game_by_id(db: Session, game_id: int): + return repository.get_game_by_id(db, game_id) + + +def search_games(db: Session, q: str): + return repository.search_games(db, q) \ No newline at end of file diff --git a/services/game-service/games.db b/services/game-service/games.db new file mode 100644 index 00000000..f4618bf6 Binary files /dev/null and b/services/game-service/games.db differ diff --git a/services/game-service/tests/test_games.py b/services/game-service/tests/test_games.py index b2533ab1..d1c5cf1c 100644 --- a/services/game-service/tests/test_games.py +++ b/services/game-service/tests/test_games.py @@ -14,3 +14,5 @@ # # Run tests with: # pytest tests/ +def test_placeholder(): + assert True \ No newline at end of file diff --git a/services/game-service/tests/test_users.py b/services/game-service/tests/test_users.py new file mode 100644 index 00000000..d46d19f7 --- /dev/null +++ b/services/game-service/tests/test_users.py @@ -0,0 +1,17 @@ +# Module 2 exercise — write your pytest tests here. +# +# Use FastAPI's TestClient to send HTTP requests to your app without +# running a real server. The client is synchronous and works out of the box. +# +# Suggested test cases to get started: +# - POST /v1/users/ with valid data returns 201 and a UserOut body +# - POST /v1/users/ with a duplicate username returns 4xx +# - GET /v1/users/{id} with a valid ID returns 200 and the correct user +# - GET /v1/users/{id} with an unknown ID returns 404 +# - GET /v1/users/ returns a UserList with the correct total +# +# Tip: use an in-memory SQLite database in your test fixture so tests +# never touch the real users.db file. +# +# Run tests with: +# pytest tests/