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 d5bf307c..f3416e6b 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) -- [ ] `REFLECTION.md` completed and committed +- [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) +- [x] `REFLECTION.md` completed and committed The map does not need to be perfect. It needs to be yours. + + 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. 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/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/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/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/routes.py b/services/game-service/app/routes.py index db173fd2..122c16c7 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_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, 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)): + 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 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 diff --git a/services/game-service/app/service.py b/services/game-service/app/service.py index 78c4451a..857d4752 100644 --- a/services/game-service/app/service.py +++ b/services/game-service/app/service.py @@ -8,3 +8,47 @@ # - 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 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: + 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 diff --git a/services/game-service/games.db b/services/game-service/games.db new file mode 100644 index 00000000..e8e95c62 Binary files /dev/null and b/services/game-service/games.db differ 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 00000000..5b5f739b Binary files /dev/null and b/services/game-service/test_games.db differ diff --git a/services/game-service/tests/test_games.py b/services/game-service/tests/test_games.py index b2533ab1..4857c715 100644 --- a/services/game-service/tests/test_games.py +++ b/services/game-service/tests/test_games.py @@ -14,3 +14,159 @@ # # Run tests with: # pytest tests/ + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.main import app +from app.database import Base, get_db + +TEST_DATABASE_URL = "sqlite:///./test_games.db" + +engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False}) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture(autouse=True) +def setup_database(): + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + + +client = TestClient(app) + +SAMPLE_GAME = { + "title": "The Legend of Zelda", + "genre": "Adventure", + "platform": "Nintendo Switch", + "release_year": 2017, + "cover_url": "https://example.com/zelda.jpg", +} + + +def test_create_game_returns_201(): + response = client.post("/v1/games/", json=SAMPLE_GAME) + assert response.status_code == 201 + data = response.json() + assert data["title"] == "The Legend of Zelda" + assert data["genre"] == "Adventure" + assert data["release_year"] == 2017 + assert "id" in data + + +def test_create_game_without_optional_fields(): + response = client.post("/v1/games/", json={ + "title": "Tetris", + "genre": "Puzzle", + "platform": "Game Boy", + }) + assert response.status_code == 201 + data = response.json() + assert data["release_year"] is None + assert data["cover_url"] is None + + +def test_create_game_missing_required_field_returns_422(): + response = client.post("/v1/games/", json={ + "title": "Tetris", + }) + assert response.status_code == 422 + + +def test_list_games_empty(): + response = client.get("/v1/games/") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + +def test_list_games_after_create(): + client.post("/v1/games/", json=SAMPLE_GAME) + client.post("/v1/games/", json={ + "title": "Super Mario", + "genre": "Platformer", + "platform": "Nintendo Switch", + }) + + data = client.get("/v1/games/").json() + assert data["total"] == 2 + assert len(data["items"]) == 2 + + +def test_list_games_pagination(): + for i in range(5): + client.post("/v1/games/", json={ + "title": f"Game {i}", + "genre": "Action", + "platform": "PC", + }) + + data = client.get("/v1/games/?limit=2&offset=0").json() + assert data["total"] == 5 + assert len(data["items"]) == 2 + assert data["limit"] == 2 + assert data["offset"] == 0 + + +def test_get_game_by_id_returns_200(): + created = client.post("/v1/games/", json=SAMPLE_GAME).json() + response = client.get(f"/v1/games/{created['id']}") + assert response.status_code == 200 + assert response.json()["id"] == created["id"] + assert response.json()["title"] == "The Legend of Zelda" + + +def test_get_game_unknown_id_returns_404(): + response = client.get("/v1/games/does-not-exist") + assert response.status_code == 404 + + +def test_search_finds_match(): + client.post("/v1/games/", json=SAMPLE_GAME) + response = client.get("/v1/games/search?q=zelda") + assert response.status_code == 200 + assert response.json()["total"] == 1 + assert response.json()["items"][0]["title"] == "The Legend of Zelda" + + +def test_search_case_insensitive(): + client.post("/v1/games/", json=SAMPLE_GAME) + assert client.get("/v1/games/search?q=ZELDA").json()["total"] == 1 + assert client.get("/v1/games/search?q=Zelda").json()["total"] == 1 + + +def test_search_partial_match(): + client.post("/v1/games/", json=SAMPLE_GAME) + client.post("/v1/games/", json={ + "title": "Zelda: Breath of the Wild", + "genre": "Adventure", + "platform": "Nintendo Switch", + }) + assert client.get("/v1/games/search?q=zelda").json()["total"] == 2 + + +def test_search_no_match_returns_empty(): + client.post("/v1/games/", json=SAMPLE_GAME) + data = client.get("/v1/games/search?q=mario").json() + assert data["total"] == 0 + assert data["items"] == [] + + +def test_search_missing_q_returns_422(): + response = client.get("/v1/games/search") + assert response.status_code == 422 \ No newline at end of file diff --git a/services/user-service/alembic.ini b/services/user-service/alembic.ini new file mode 100644 index 00000000..421007a0 --- /dev/null +++ b/services/user-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:///./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/app/database.py b/services/user-service/app/database.py index a5fe24f9..885ae2ac 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, DeclarativeBase +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/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/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/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/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 diff --git a/services/user-service/app/service.py b/services/user-service/app/service.py index 39143a27..93ea630b 100644 --- a/services/user-service/app/service.py +++ b/services/user-service/app/service.py @@ -17,3 +17,37 @@ # with passlib in Module 6. # # 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) + 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: + 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 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 00000000..16473edb Binary files /dev/null and b/services/user-service/test_users.db differ diff --git a/services/user-service/tests/test_users.py b/services/user-service/tests/test_users.py index d46d19f7..3fd003cf 100644 --- a/services/user-service/tests/test_users.py +++ b/services/user-service/tests/test_users.py @@ -15,3 +15,104 @@ # # Run tests with: # pytest tests/ + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.main import app +from app.database import Base, get_db + +TEST_DATABASE_URL = "sqlite:///./test_users.db" + +engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False}) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture(autouse=True) +def setup_database(): + """Create all tables before each test, drop them after.""" + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + + +client = TestClient(app) + +SAMPLE_USER = { + "username": "iulian", + "email": "iulian@example.com", + "password": "secret123", +} + + +def test_create_user_returns_201(): + response = client.post("/v1/users/", json=SAMPLE_USER) + assert response.status_code == 201 + data = response.json() + assert data["username"] == "iulian" + assert data["email"] == "iulian@example.com" + assert "id" in data + assert "password" not in data + + +def test_create_user_duplicate_username_returns_4xx(): + client.post("/v1/users/", json=SAMPLE_USER) + response = client.post("/v1/users/", json=SAMPLE_USER) + assert response.status_code >= 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 diff --git a/services/user-service/users.db b/services/user-service/users.db new file mode 100644 index 00000000..fa896ab5 Binary files /dev/null and b/services/user-service/users.db differ