From 86b80ae0f8e2f7d5998c8bbdc0299f04c5fb665c Mon Sep 17 00:00:00 2001 From: Iulian Asanache Date: Wed, 3 Jun 2026 11:03:43 +0200 Subject: [PATCH 01/12] docs: exercice 1 completed --- modules/module-01/exercise.md | 59 ++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/modules/module-01/exercise.md b/modules/module-01/exercise.md index d5bf307c..7befa7aa 100644 --- a/modules/module-01/exercise.md +++ b/modules/module-01/exercise.md @@ -26,11 +26,16 @@ A bounded context is a part of the system that has a clear responsibility and ow 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 | _(fill in)_ | _(fill in)_ | _(fill in)_ | -| _(add more)_ | | | | +## Iulian ## + +| Bounded Context | Responsibilities | Owned Entities | Team | +| --------------- | -------------------------------------------------------- | ----------------| ----------- | +| Identity | Manages who users are, handles registration and profiles | User, Session | Platform | +| Game Library | Manages the game catalog, genres, search and game metadata | Game, Genre, Tag | Catalog | +| Activity | Records what users play, follow and rate, serves the social feed | ActivityEvent, Follow, Rating | Social | +| Logging | Stores GDPR consent status, records activity only for opted-in users | ConsentRecord, ActivityLog | Compliance | +| Notification | Delivers alerts via push and email, manages notification preferences | Notification, NotificationPreference | Platform | + There is no single correct answer: what matters is that you can justify each row. @@ -45,6 +50,29 @@ For each pair of services that need to communicate, define: - **Protocol**: REST or event (async) - **Payload**: key fields exchanged +## Iulian ## + +**Flow 1** + +- **Direction**: client -> gateway -> auth-service +- **Trigger**: User submits login form +- **Protocol**: REST (synchronous or how it is written) - Client is waiting for the token +- **Payload**: {email, password} -> returns {jwt_token} + +**Flow 2** + +- **Direction**: activity-service -> logging-service +- **Trigger**: An activity event is recorded +- **Protocol**: RabbitMQ message - activity-service shouldn't wait or fail if logging is slow +- **Payload**: {activity_id, user_id, action, game_id, timestamp} + +**Flow 3** + +- **Direction**: activity-service -> notification-service +- **Trigger**: A followed froend starts playing +- **Protocol**: Still a RabbitMQ message - Notification dlelivery can be slow so like this the activity write is not blocked +- **Payload**: {email, password} -> returns {jwt_token} + Example: ``` @@ -69,25 +97,40 @@ Draw the full GameHub service map: This can be a sketch on paper, a whiteboard photo, or ASCII art committed to your branch. +## It is made in DrawIO ## + --- ## 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? + +- Because microservices have the best tools fot your job. Notification delivery involves holding many open connections and Node.js has the event loop (we did this in Server-side JS) which was built for this. Python would work, but Node.js is more natural here. The key insight is that services are isolated enough that a different language in one place doesn't contaminate the others. + + 2. What is the risk of `activity-service` calling `logging-service` synchronously — why might you prefer an async event instead? + +- If logging-service is slow, or down, activity-service would be blocked waiting or it would fail. Every user action would feel not-user-friendly, and a logging outage would break the entire activity feature. Using an async event instead, activity-service fires the event and moves on immediately. Logging-service processes it when it can. The two services fail independently (unfortunetley). + 3. Why does `logging-service` need a GDPR consent check before recording any activity? +- EU law requires that you have the user's explicit consent before tracking their behavior (GDPR class). If you record first and check later, you've already violated the regulation. Logging-service is the gatekeeper: it receives the activity event, checks its own consent table for that user, and only writes the record if consent exists. The consent data lives in logging-service's database and nobody else owns it. + +''' You do not need to write these answers down — they are warm-up for your REFLECTION.md. --- ## Minimum to submit this branch -- [ ] Bounded context table filled in (at least 4 services justified) -- [ ] At least 3 service contracts defined -- [ ] Service map committed (sketch, photo, or ASCII) +- [x] Bounded context table filled in (at least 4 services justified) +- [x] At least 3 service contracts defined +- [x] Service map committed (sketch, photo, or ASCII) - [ ] `REFLECTION.md` completed and committed The map does not need to be perfect. It needs to be yours. + + From f83bed39e056ff598a834b644d7c5c9de1903835 Mon Sep 17 00:00:00 2001 From: Iulian Asanache Date: Wed, 3 Jun 2026 11:22:39 +0200 Subject: [PATCH 02/12] docs: reflections --- modules/module-01/REFLECTION.md | 40 ++++++++++++++++++++++++++++----- modules/module-01/exercise.md | 6 ++--- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/modules/module-01/REFLECTION.md b/modules/module-01/REFLECTION.md index ea9859e7..77030244 100644 --- a/modules/module-01/REFLECTION.md +++ b/modules/module-01/REFLECTION.md @@ -4,9 +4,9 @@ # Module 1 — Reflection -**Team name**: **\*\***\_\_\_**\*\*** -**Branch**: `module-01/` -**Submitted**: before Module 2 lesson +**Team name**: **Iulian - just me** +**Branch**: `module-01/iulian` +**Submitted**: Unfortuneltey a bit late 👉👈 --- @@ -22,8 +22,17 @@ You started from a painful monolith. Now you're splitting it into separate servi Think about it from three angles: the developer who has to change code, the team that has to deploy it, and the user who has to live with its failures. You don't need to cover all three, pick the one that felt most real to you today. -> _Your answer:_ +``` +It solves a problem for the developer who has to change code. +In a monolith, changing the logging logic means opening the same codebase +that handles login, games, and notifications. You risk breaking something +unrelated every time you deploy. + +With separate services, you change one thing, deploy one thing, and nothing +else is at risk. + +``` --- ## 2. Your choice @@ -34,7 +43,16 @@ Look at your service map. Every arrow between two services is a decision someone What would break, slow down, or become harder to manage if you merged those two services back together? -> _Your answer:_ +``` +The line between activity-service and logging-service. + +They both deal with what users do, but for completely different reasons. +activity-service powers the social feed. logging-service exists because of +GDPR — it has to check consent before writing anything down. + +If I merged them, a bug in the feed logic could accidentally bypass the +consent check. Two very different responsibilities tangled in one place. +``` --- @@ -46,7 +64,17 @@ Microservices solve the monolith's problems. But they create new ones. No need to solve it: just name it honestly. This is exactly the tension the rest of the course is about. -> _Your answer:_ +``` +In the monolith, "did this user consent AND what did they play today" is +one SQL query joining two tables. + +In the distributed design, that data lives in two separate services that +cannot touch each other's database. A simple question became a distributed +systems problem. + +I don't have a solution yet — I think that's what the rest of the course +is for. +``` --- diff --git a/modules/module-01/exercise.md b/modules/module-01/exercise.md index 7befa7aa..f3416e6b 100644 --- a/modules/module-01/exercise.md +++ b/modules/module-01/exercise.md @@ -105,7 +105,7 @@ This can be a sketch on paper, a whiteboard photo, or ASCII art committed to you 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? - Because microservices have the best tools fot your job. Notification delivery involves holding many open connections and Node.js has the event loop (we did this in Server-side JS) which was built for this. Python would work, but Node.js is more natural here. The key insight is that services are isolated enough that a different language in one place doesn't contaminate the others. @@ -119,7 +119,7 @@ Three questions to discuss as a team before you leave: - EU law requires that you have the user's explicit consent before tracking their behavior (GDPR class). If you record first and check later, you've already violated the regulation. Logging-service is the gatekeeper: it receives the activity event, checks its own consent table for that user, and only writes the record if consent exists. The consent data lives in logging-service's database and nobody else owns it. -''' +``` You do not need to write these answers down — they are warm-up for your REFLECTION.md. --- @@ -129,7 +129,7 @@ You do not need to write these answers down — they are warm-up for your REFLEC - [x] Bounded context table filled in (at least 4 services justified) - [x] At least 3 service contracts defined - [x] Service map committed (sketch, photo, or ASCII) -- [ ] `REFLECTION.md` completed and committed +- [x] `REFLECTION.md` completed and committed The map does not need to be perfect. It needs to be yours. From 69f3179a66eb22b6fc5b02a95cebbab3460fd910 Mon Sep 17 00:00:00 2001 From: Iulian Asanache Date: Wed, 3 Jun 2026 14:07:12 +0200 Subject: [PATCH 03/12] feat: DB, User and models implemented --- services/user-service/app/database.py | 19 +++++++++++++++++++ services/user-service/app/models.py | 16 ++++++++++++++++ services/user-service/app/repository.py | 23 +++++++++++++++++++++++ services/user-service/app/schemas.py | 23 +++++++++++++++++++++++ 4 files changed, 81 insertions(+) diff --git a/services/user-service/app/database.py b/services/user-service/app/database.py index a5fe24f9..26f30fa5 100644 --- a/services/user-service/app/database.py +++ b/services/user-service/app/database.py @@ -10,3 +10,22 @@ # It is pure infrastructure. # # See the README for the full implementation. + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +import os + +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) + +class Base(DeclarativeBase): + pass + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/services/user-service/app/models.py b/services/user-service/app/models.py index 03e8cdba..3406abe9 100644 --- a/services/user-service/app/models.py +++ b/services/user-service/app/models.py @@ -11,3 +11,19 @@ # Rule: no business logic here. This file only describes data structure. # # See the README for the full implementation. + + +from sqlalchemy import Column, String, Boolean, DateTime +from datetime import datetime, timezone +import uuid +from app.database import Base + +class User(Base): + __tablename__ = "users" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + 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)) \ No newline at end of file diff --git a/services/user-service/app/repository.py b/services/user-service/app/repository.py index f519c460..eeb60933 100644 --- a/services/user-service/app/repository.py +++ b/services/user-service/app/repository.py @@ -14,3 +14,26 @@ # - 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 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 \ No newline at end of file diff --git a/services/user-service/app/schemas.py b/services/user-service/app/schemas.py index f2627a95..bbc65e78 100644 --- a/services/user-service/app/schemas.py +++ b/services/user-service/app/schemas.py @@ -13,3 +13,26 @@ # read directly from SQLAlchemy ORM objects. # # See the README for the full implementation. + +from pydantic import BaseModel +from datetime import datetime + +class UserCreate(BaseModel): + username: str + email: str + password: str + +class UserOut(BaseModel): + id: str + username: str + email: str + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + +class UserList(BaseModel): + items: list[UserOut] + total: int + limit: int + offset: int \ No newline at end of file From 11f1750ab6e46704b6f4260b20533cc5ef97543e Mon Sep 17 00:00:00 2001 From: Iulian Asanache Date: Wed, 3 Jun 2026 14:13:10 +0200 Subject: [PATCH 04/12] feat: routes and services set --- services/user-service/app/main.py | 6 ++++++ services/user-service/app/routes.py | 22 ++++++++++++++++++++++ services/user-service/app/service.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/services/user-service/app/main.py b/services/user-service/app/main.py index a4463d42..d474dd9c 100644 --- a/services/user-service/app/main.py +++ b/services/user-service/app/main.py @@ -9,3 +9,9 @@ # Then open: http://localhost:8001/docs # # See the README for the full implementation. + +from fastapi import FastAPI +from app.routes import router + +app = FastAPI(title="user-service") +app.include_router(router) \ No newline at end of file diff --git a/services/user-service/app/routes.py b/services/user-service/app/routes.py index 176c60cd..074f3017 100644 --- a/services/user-service/app/routes.py +++ b/services/user-service/app/routes.py @@ -14,3 +14,25 @@ # - 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, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app import service, schemas + +router = APIRouter(prefix="/v1/users", tags=["users"]) + +@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("/", 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("/{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)) \ No newline at end of file diff --git a/services/user-service/app/service.py b/services/user-service/app/service.py index 39143a27..4557b44b 100644 --- a/services/user-service/app/service.py +++ b/services/user-service/app/service.py @@ -17,3 +17,31 @@ # 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 UserCreate, UserOut, UserList + +def _hash_password(plain: str) -> str: + 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, + ) \ No newline at end of file From c469f69252ed95849b9b1ba9525ae01462c05877 Mon Sep 17 00:00:00 2001 From: Iulian Asanache Date: Wed, 3 Jun 2026 14:20:50 +0200 Subject: [PATCH 05/12] fix: database.py typos --- services/user-service/app/database.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/user-service/app/database.py b/services/user-service/app/database.py index 26f30fa5..885ae2ac 100644 --- a/services/user-service/app/database.py +++ b/services/user-service/app/database.py @@ -12,10 +12,10 @@ # See the README for the full implementation. from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import sessionmaker, DeclarativeBase import os -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///.users.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) From 0c078c8ae76ebacd8c363c7dbeb1a16a668e1b13 Mon Sep 17 00:00:00 2001 From: Iulian Asanache Date: Wed, 3 Jun 2026 15:10:11 +0200 Subject: [PATCH 06/12] feat: Database and models set for Game - Service --- services/game-service/app/database.py | 19 ++++++++++++++ services/game-service/app/models.py | 16 ++++++++++++ services/game-service/app/repository.py | 33 +++++++++++++++++++++++++ services/game-service/app/schemas.py | 27 ++++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/services/game-service/app/database.py b/services/game-service/app/database.py index 56dfd44d..ff8cd719 100644 --- a/services/game-service/app/database.py +++ b/services/game-service/app/database.py @@ -8,3 +8,22 @@ # - 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 + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./games.db") + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +class Base(DeclarativeBase): + pass + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/services/game-service/app/models.py b/services/game-service/app/models.py index 52eb2756..b1ffa388 100644 --- a/services/game-service/app/models.py +++ b/services/game-service/app/models.py @@ -12,3 +12,19 @@ # - created_at DateTime, defaults to now (UTC) # # Import Base from app.database — do not redefine it here. + +from sqlalchemy import Column, String, Integer, DateTime +from datetime import datetime, timezone +import uuid +from app.database import Base + +class Game(Base): + __tablename__ = "games" + + 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) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) \ No newline at end of file diff --git a/services/game-service/app/repository.py b/services/game-service/app/repository.py index 8120c970..20356180 100644 --- a/services/game-service/app/repository.py +++ b/services/game-service/app/repository.py @@ -8,3 +8,36 @@ # - 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 + +from sqlalchemy.orm import Session +from app.models import Game +from app.schemas import GameCreate + +def create_game(db: Session, data: GameCreate) -> Game: + game = Game( + title = data.title, + genre = data.genre, + platform = data.platform, + release_year = data.release_year, + cover_url = data.cover_url, + ) + db.add(game) + db.commit() + db.refresh(game) + + return game + +def get_game(db: Session, game_id: str) -> Game | None: + return db.query(Game).filter(Game.id == game_id).first() + +def list_games(db: Session, limit: int = 20, offset: int = 0) -> tuple[list[Game], int]: + total = db.query(Game).count() + game = db.query(Game).offset(offset).limit(limit).all() + return game, total + + +def search_games(db: Session, q: str, limit: int = 20, offset: int = 0) -> tuple[list[Game], int]: + query = db.query(Game).filter(Game.title.ilike(f"%{q}%")) + total = query.count() + games = query.offset(offset).limit(limit).all() + return games, total \ No newline at end of file diff --git a/services/game-service/app/schemas.py b/services/game-service/app/schemas.py index ca6aca4e..c8cbbd2e 100644 --- a/services/game-service/app/schemas.py +++ b/services/game-service/app/schemas.py @@ -8,3 +8,30 @@ # - GameOut — fields returned to the caller (includes id and created_at) # add model_config = {"from_attributes": True} # - GameList — paginated envelope: { items, total, limit, offset } + +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 GameOut(BaseModel): + id: str + title: str + genre: str + platform: str + release_year: int | None + cover_url: str | None + created_at: datetime + + model_config = {"from_attributes": True} + +class GameList(BaseModel): + items: list[GameOut] + total: int + limit: int + offset: int \ No newline at end of file From fa0b9377ed3f6151cefdd330594499760611a84c Mon Sep 17 00:00:00 2001 From: Iulian Asanache Date: Wed, 3 Jun 2026 15:25:59 +0200 Subject: [PATCH 07/12] feat: Services suchs as create, list, find and fetch implemented --- services/game-service/app/service.py | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/services/game-service/app/service.py b/services/game-service/app/service.py index 78c4451a..95f69b14 100644 --- a/services/game-service/app/service.py +++ b/services/game-service/app/service.py @@ -8,3 +8,36 @@ # - 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) + +from sqlalchemy.orm import Session +from app import repository +from app.schemas import GameCreate, GameOut, GameList + +def add_game(db: Session, data: GameCreate) -> GameOut: + game = repository.create_game(db, data) + return GameOut.model_validate(game) + +def fetch_game(db: Session, game_id: str) -> GameOut: + game = repository.get_game(db, game_id) + if game is None: + raise ValueError(f"Game {game_id} not found") + return GameOut.model_validate(game) + +def fetch_all_games(db: Session, limit: int = 20, offset: int = 0) -> GameList: + games, total = repository.list_games(db, limit=limit, offset=offset) + return GameList( + items=[GameOut.model_validate(g) for g in games], + total=total, + limit=limit, + offset=offset, + ) + + +def find_games(db: Session, q: str, limit: int = 20, offset: int = 0) -> GameList: + games, total = repository.search_games(db, q=q, limit=limit, offset=offset) + return GameList( + items=[GameOut.model_validate(g) for g in games], + total=total, + limit=limit, + offset=offset, + ) \ No newline at end of file From 285fea2c8e79f6ca65a6510958e8240e0f42fe6a Mon Sep 17 00:00:00 2001 From: Iulian Asanache Date: Wed, 3 Jun 2026 19:03:10 +0200 Subject: [PATCH 08/12] add: routes and tested main.py --- services/game-service/app/main.py | 6 ++++++ services/game-service/app/routes.py | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/services/game-service/app/main.py b/services/game-service/app/main.py index e629900e..99cbf0ce 100644 --- a/services/game-service/app/main.py +++ b/services/game-service/app/main.py @@ -7,3 +7,9 @@ # uvicorn app.main:app --reload --port 8002 # # Then open: http://localhost:8002/docs + +from fastapi import FastAPI +from app.routes import router + +app = FastAPI(title="game-service") +app.include_router(router) \ No newline at end of file diff --git a/services/game-service/app/routes.py b/services/game-service/app/routes.py index db173fd2..896cf7eb 100644 --- a/services/game-service/app/routes.py +++ b/services/game-service/app/routes.py @@ -9,3 +9,29 @@ # 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. + +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)): + return service.add_game(db, data) + +@router.get("/", response_model=schemas.GameList) +def list_games(limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): + return service.fetch_all_game(db, limit=limit, offset=offset) + +@router.get("/search", response_model=schemas.GameList) +def search_games(q: str, limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): + return service.find_games(db, limit=limit, offset=offset) + +@router.get("/{game_id}", response_model=schemas.GameOut) +def get_game(game_id: str, db: Session = Depends(get_db)): + try: + return service.fetch_game(db, game_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) \ No newline at end of file From 16d20675cb71a7dba686fba9f46295e0fc49e87b Mon Sep 17 00:00:00 2001 From: Iulian Asanache Date: Wed, 3 Jun 2026 19:26:41 +0200 Subject: [PATCH 09/12] chore: alembic set + fixing typos --- services/game-service/alembic.ini | 149 ++++++++++++++++++ services/game-service/alembic/README | 1 + services/game-service/alembic/env.py | 84 ++++++++++ services/game-service/alembic/script.py.mako | 28 ++++ .../5fe7c93900e1_create_users_table.py | 41 +++++ services/game-service/app/routes.py | 4 +- services/game-service/games.db | Bin 0 -> 20480 bytes services/user-service/alembic.ini | 149 ++++++++++++++++++ services/user-service/alembic/README | 1 + services/user-service/alembic/env.py | 84 ++++++++++ services/user-service/alembic/script.py.mako | 28 ++++ .../9614b1a33894_create_users_table.py | 42 +++++ services/user-service/users.db | Bin 0 -> 28672 bytes 13 files changed, 609 insertions(+), 2 deletions(-) create mode 100644 services/game-service/alembic.ini create mode 100644 services/game-service/alembic/README create mode 100644 services/game-service/alembic/env.py create mode 100644 services/game-service/alembic/script.py.mako create mode 100644 services/game-service/alembic/versions/5fe7c93900e1_create_users_table.py create mode 100644 services/game-service/games.db create mode 100644 services/user-service/alembic.ini create mode 100644 services/user-service/alembic/README create mode 100644 services/user-service/alembic/env.py create mode 100644 services/user-service/alembic/script.py.mako create mode 100644 services/user-service/alembic/versions/9614b1a33894_create_users_table.py create mode 100644 services/user-service/users.db diff --git a/services/game-service/alembic.ini b/services/game-service/alembic.ini new file mode 100644 index 00000000..eab005a0 --- /dev/null +++ b/services/game-service/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/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 +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be 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 /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 "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +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 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///./games.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 module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[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/services/game-service/alembic/README b/services/game-service/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/services/game-service/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/services/game-service/alembic/env.py b/services/game-service/alembic/env.py new file mode 100644 index 00000000..b9bc4d9f --- /dev/null +++ b/services/game-service/alembic/env.py @@ -0,0 +1,84 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# 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 +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from app.database import Base +from app.models import Game + +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. + + """ + url = config.get_main_option("sqlalchemy.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/services/game-service/alembic/script.py.mako b/services/game-service/alembic/script.py.mako new file mode 100644 index 00000000..11016301 --- /dev/null +++ b/services/game-service/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${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, Sequence[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: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/services/game-service/alembic/versions/5fe7c93900e1_create_users_table.py b/services/game-service/alembic/versions/5fe7c93900e1_create_users_table.py new file mode 100644 index 00000000..f3d36153 --- /dev/null +++ b/services/game-service/alembic/versions/5fe7c93900e1_create_users_table.py @@ -0,0 +1,41 @@ +"""create users table + +Revision ID: 5fe7c93900e1 +Revises: +Create Date: 2026-06-03 19:18:34.320658 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5fe7c93900e1' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('games', + sa.Column('id', sa.String(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('genre', sa.String(), nullable=False), + sa.Column('platform', sa.String(), nullable=False), + sa.Column('release_year', sa.Integer(), nullable=True), + sa.Column('cover_url', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('games') + # ### end Alembic commands ### diff --git a/services/game-service/app/routes.py b/services/game-service/app/routes.py index 896cf7eb..122c16c7 100644 --- a/services/game-service/app/routes.py +++ b/services/game-service/app/routes.py @@ -23,11 +23,11 @@ def create_game(data: schemas.GameCreate, db: Session = Depends(get_db)): @router.get("/", response_model=schemas.GameList) def list_games(limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): - return service.fetch_all_game(db, limit=limit, offset=offset) + return service.fetch_all_games(db, limit=limit, offset=offset) @router.get("/search", response_model=schemas.GameList) def search_games(q: str, limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): - return service.find_games(db, limit=limit, offset=offset) + return service.find_games(db, q=q, limit=limit, offset=offset) @router.get("/{game_id}", response_model=schemas.GameOut) def get_game(game_id: str, db: Session = Depends(get_db)): diff --git a/services/game-service/games.db b/services/game-service/games.db new file mode 100644 index 0000000000000000000000000000000000000000..e8e95c62699024287234dd8551a150be35291ca5 GIT binary patch literal 20480 zcmeI&J#X4T7{Ku}kfb0Hcc|p`mPlAsRVa$843X**t3@TGaokFoEC();6$7a;Q9GvH z`i=T^`UyH@=-9IrDzISdg#ITE-;3|e^E1mM`~0jIrAl5+l2Mq-wsdXGe zUhlcARnsVq)ykP`HBMH~+{R&=<(ey(B`Q|oR0UsEn8%KP9?!CiRY{G z!9<0r8U{LRJ=VY3@1D5X@CUDZ;&>PGy?Y@W(a^5hc4gZ-vJ4T8hw97pbF9 zydUI22Ce+Cb#EIxR@OlEA4%YyOjU$~9-OMA(% zbJ{=mJx2#C3p)h2Hv_p8&BA^/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 "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +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 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///./users.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 module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[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/services/user-service/alembic/README b/services/user-service/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/services/user-service/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/services/user-service/alembic/env.py b/services/user-service/alembic/env.py new file mode 100644 index 00000000..35423b14 --- /dev/null +++ b/services/user-service/alembic/env.py @@ -0,0 +1,84 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# 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 +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from app.database import Base +from app.models import User + +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. + + """ + url = config.get_main_option("sqlalchemy.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/services/user-service/alembic/script.py.mako b/services/user-service/alembic/script.py.mako new file mode 100644 index 00000000..11016301 --- /dev/null +++ b/services/user-service/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${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, Sequence[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: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/services/user-service/alembic/versions/9614b1a33894_create_users_table.py b/services/user-service/alembic/versions/9614b1a33894_create_users_table.py new file mode 100644 index 00000000..db84e2ed --- /dev/null +++ b/services/user-service/alembic/versions/9614b1a33894_create_users_table.py @@ -0,0 +1,42 @@ +"""create users table + +Revision ID: 9614b1a33894 +Revises: +Create Date: 2026-06-03 19:15:37.157352 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9614b1a33894' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('username') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('users') + # ### end Alembic commands ### diff --git a/services/user-service/users.db b/services/user-service/users.db new file mode 100644 index 0000000000000000000000000000000000000000..fa896ab5ea9dfb5dc092499c98ff017ec0838b52 GIT binary patch literal 28672 zcmeI&Pixv>9Ki8Jtxh4j!ywn^QZP6M)-u*ZVW|sDX5&obpr;TuTSNWRnb_^Rj$X#z z&)D19E7)RnTIE#aDHT}z}@qFyP7WAVXs+HXxq*gaR+ zgKqb^sFaiTnLiD7HU?8aoa}75@}sL@?A`cL^ku%-J#`p){wNM_198^tbuH6QK5w)L z{8)~ZiQ^l&O1E=vCDrep&bjGaig(tfsE1=CsT-m53{9pd*p928| z5I_I{1Q0*~0R#|0009ItERa0^C;!iIfY}-X2q1s}0tg_000IagfB*tgfc&4Y0Rjjh rfB*srAb Date: Wed, 3 Jun 2026 19:33:11 +0200 Subject: [PATCH 10/12] fix: Integrity and HTTP raise error missing from service --- services/user-service/app/service.py | 8 +- services/user-service/pytest.ini | 2 + services/user-service/test_users.db | Bin 0 -> 20480 bytes services/user-service/tests/test_users.py | 101 ++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 services/user-service/pytest.ini create mode 100644 services/user-service/test_users.db diff --git a/services/user-service/app/service.py b/services/user-service/app/service.py index 4557b44b..93ea630b 100644 --- a/services/user-service/app/service.py +++ b/services/user-service/app/service.py @@ -19,15 +19,21 @@ # See the README for the full implementation. from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError from app import repository from app.schemas import UserCreate, UserOut, UserList +from fastapi import HTTPException def _hash_password(plain: str) -> str: return plain + "_hashed" def add_user(db: Session, data: UserCreate) -> UserOut: hashed = _hash_password(data.password) - user = repository.create_user(db, data, hashed) + try: + user = repository.create_user(db, data, hashed) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="Username or email already exists") return UserOut.model_validate(user) def fetch_user(db: Session, user_id: str) -> UserOut: diff --git a/services/user-service/pytest.ini b/services/user-service/pytest.ini new file mode 100644 index 00000000..a635c5c0 --- /dev/null +++ b/services/user-service/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/services/user-service/test_users.db b/services/user-service/test_users.db new file mode 100644 index 0000000000000000000000000000000000000000..16473edb012714361948756bdd95be123e4e45e3 GIT binary patch literal 20480 zcmeI(J#W)M7zc1WPJmPbGl0TqiGfz?wdcF@8xj%$B2f`q2n`YggtL97k(w5olrn?F zz$f5a@HN<2VPini%wpQLg32t66D6olVX9t!tUQzjqHtz; zintJkPys0i6%iQ|M8Y1|3R7?UbLR?~b@9BmsEcdZ1+z;+&_w5O~$#E$VfB*y_009U<00Izz00bZafxi$~ zbo%x@(9lO~phlzW@p{!`PSx{kXPlN= 400 + + +def test_get_user_by_id_returns_200(): + created = client.post("/v1/users/", json=SAMPLE_USER).json() + user_id = created["id"] + + response = client.get(f"/v1/users/{user_id}") + assert response.status_code == 200 + assert response.json()["id"] == user_id + + +def test_get_user_unknown_id_returns_404(): + response = client.get("/v1/users/does-not-exist") + assert response.status_code == 404 + + +def test_list_users_returns_correct_total(): + assert client.get("/v1/users/").json()["total"] == 0 + + client.post("/v1/users/", json=SAMPLE_USER) + client.post("/v1/users/", json={ + "username": "second_user", + "email": "second@example.com", + "password": "pass456", + }) + + data = client.get("/v1/users/").json() + assert data["total"] == 2 + assert len(data["items"]) == 2 + + +def test_list_users_pagination(): + for i in range(5): + client.post("/v1/users/", json={ + "username": f"user{i}", + "email": f"user{i}@example.com", + "password": "pass", + }) + + response = client.get("/v1/users/?limit=2&offset=0") + data = response.json() + assert data["total"] == 5 + assert len(data["items"]) == 2 + assert data["limit"] == 2 + assert data["offset"] == 0 \ No newline at end of file From 794943a5ff3744c7343ed14b093aad4dd622e1c3 Mon Sep 17 00:00:00 2001 From: Iulian Asanache Date: Wed, 3 Jun 2026 19:37:44 +0200 Subject: [PATCH 11/12] test: game-service tested and passed --- services/game-service/app/service.py | 11 ++ services/game-service/pytest.ini | 2 + services/game-service/test_games.db | Bin 0 -> 12288 bytes services/game-service/tests/test_games.py | 156 ++++++++++++++++++++++ 4 files changed, 169 insertions(+) create mode 100644 services/game-service/pytest.ini create mode 100644 services/game-service/test_games.db diff --git a/services/game-service/app/service.py b/services/game-service/app/service.py index 95f69b14..857d4752 100644 --- a/services/game-service/app/service.py +++ b/services/game-service/app/service.py @@ -11,12 +11,23 @@ from sqlalchemy.orm import Session from app import repository +from sqlalchemy.exc import IntegrityError from app.schemas import GameCreate, GameOut, GameList +from fastapi import HTTPException def add_game(db: Session, data: GameCreate) -> GameOut: game = repository.create_game(db, data) return GameOut.model_validate(game) +def add_user(db: Session, data: GameCreate) -> GameOut: + game = repository.create_game(db, data) + try: + user = repository.create_user(db, data, game) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="Username or email already exists") + return GameOut.model_validate(user) + def fetch_game(db: Session, game_id: str) -> GameOut: game = repository.get_game(db, game_id) if game is None: diff --git a/services/game-service/pytest.ini b/services/game-service/pytest.ini new file mode 100644 index 00000000..a635c5c0 --- /dev/null +++ b/services/game-service/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/services/game-service/test_games.db b/services/game-service/test_games.db new file mode 100644 index 0000000000000000000000000000000000000000..5b5f739bc10f9c0343ae19cdfb30439e198e8423 GIT binary patch literal 12288 zcmeI%Pm9zr6aetHv;LU@=^lguPq{e-BW;tW%|UQniw@3?T06oXq$JIoW!UbHcDkZR z5j^;1{3QFGY>RUn_ORe#;T2xfCNE9;yFK#Z{e0a3S#9cb)evSKTDEOHC&aQWuZ_XK zaN71!8wYLdE_?TIP3u`te?NTCB^+9od*xnTlZ!(rfC4Ch0w{n2D1ZVefC4Ch0w{1( zfgfk~z43T#|BM=?D>zl>umWHK7A4f#0Dj$ThQvUr=5#d1D> zLPo=8-Bhr5 zKUoBQ)7AC&JJelmep6tahXN>o0w{n2D1ZVefC4Ch0w{n2C~#{8y0d)8{p0>}Ki!%= z<3>>c1yBG5Pyhu`00mG01yBG5P~g7`Oa@C^a>ivMLn?#-%4HnUq?AB~3L_o?^AoA> zbcvv~5J|wOk(yDi6{AucNqK0LDm4$wWOdLb8s^KyXNpFQ3(8>?Qyoe}Bg4QC_$m~! z7<7qoS Date: Wed, 3 Jun 2026 19:45:46 +0200 Subject: [PATCH 12/12] docs: Reflection (ti was did step by step with the code, but I push it now) --- modules/module-02/REFLECTION.md | 26 +++++++++++++++++++------- modules/module-02/exercise.md | 8 ++++---- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/modules/module-02/REFLECTION.md b/modules/module-02/REFLECTION.md index 52850343..ef028133 100644 --- a/modules/module-02/REFLECTION.md +++ b/modules/module-02/REFLECTION.md @@ -1,8 +1,8 @@ # Module 2 — Reflection -**Team name**: _______________ -**Branch**: `module-02/` -**Submitted**: before Module 3 lesson +**Team name**: Just me +**Branch**: `module-02/iulian` +**Submitted**: After Module 3 lesson unfortlunetley 👉👈 --- @@ -18,7 +18,10 @@ You built a service with distinct layers: models, schemas, repository, service, Think about what happens six months later when someone new joins the team, or when you need to swap SQLite for PostgreSQL. What does the layered structure protect you from? -> *Your answer:* +> If everything is in one file, swapping SQLite for PostgreSQL means +digging through HTTP handlers, business logic, and SQL all mixed together. +With layers, I only touch `database.py` and `repository.py` — the rest +doesn't know the database even exists. --- @@ -30,8 +33,11 @@ Each service owns its data exclusively — no other service is allowed to touch Give a concrete scenario, not a general principle. -> *Your answer:* - +> If another service wrote directly to the `games` table, it could insert +a game with a missing `genre` or a malformed `cover_url` that bypasses +the Pydantic validation in `game-service`. The data would land in the DB +in a broken state and my service would start returning garbage without +any error ever being raised. --- ## 3. The tradeoff @@ -42,7 +48,13 @@ You now have models, schemas, a repository, a service, and routes — five layer And at what point does the complexity start to pay off? Where is the tipping point? -> *Your answer:* +> For two endpoints it feels like overkill — I wrote five files where one +would have worked. The cost is time and mental overhead just to add a +single field. + +It starts paying off the moment a second person touches the code, or when +a test needs to mock just the repository without touching the HTTP layer. +At that point having clear boundaries is worth more than the extra files. --- diff --git a/modules/module-02/exercise.md b/modules/module-02/exercise.md index d3d7c136..8115c133 100644 --- a/modules/module-02/exercise.md +++ b/modules/module-02/exercise.md @@ -127,9 +127,9 @@ Also confirm `aiosqlite` is in your `requirements.txt` — the CI test runner us ## Minimum to submit this branch -- [ ] `game-service` running on port 8002 with all 4 endpoints working -- [ ] Alembic migration for the `games` table committed -- [ ] At least one test passing in `game-service/tests/` -- [ ] `REFLECTION.md` completed and committed +- [x] `game-service` running on port 8002 with all 4 endpoints working +- [x] Alembic migration for the `games` table committed +- [x] At least one test passing in `game-service/tests/` +- [x] `REFLECTION.md` completed and committed If you run out of time: the search endpoint is optional. The other three are not.