diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 26ed036..76c07e7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,6 +25,10 @@ jobs: filters: | tournament: - 'apps/tournament/**' + - 'migration/contracts/**' + - 'migration/python_*' + - 'migration/fixtures/**' + - 'tools/check_tournament_compat.py' - 'packages/crr-brand/**' - 'packages/crr-python/**' - 'pyproject.toml' @@ -56,6 +60,10 @@ jobs: node-version: '22' cache: npm cache-dependency-path: apps/tournament/package-lock.json + - uses: astral-sh/setup-uv@v6 + with: + version: '0.11.31' + enable-cache: true - run: make tournament-install tournament-test tournament-build - run: docker compose config --quiet - run: docker build -f apps/tournament/Dockerfile -t crr-tournament . diff --git a/Makefile b/Makefile index 0925d52..0bd9c22 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ UV ?= uv PYTHON ?= python3 .DEFAULT_GOAL := test -.PHONY: baseline-test baseline-fixtures baseline-screenshots baseline-preserve migration-visual-check branding-visual-check images-smoke-test tournament-install tournament-backend-dev tournament-frontend-dev tournament-test tournament-build shifts-dev shifts-test shifts-build test +.PHONY: baseline-test baseline-fixtures baseline-screenshots baseline-preserve migration-visual-check branding-visual-check images-smoke-test tournament-install tournament-backend-dev tournament-frontend-dev tournament-test tournament-compat-test tournament-build shifts-dev shifts-test shifts-build test baseline-test: $(PYTHON) tools/migration_baseline.py all @@ -32,14 +32,20 @@ tournament-install: npm --prefix apps/tournament ci tournament-backend-dev: - npm --prefix apps/tournament run dev:server + DATABASE_PATH=$(abspath apps/tournament/tournament.sqlite) $(UV) run --directory apps/tournament/backend --package crr-tournament --locked uvicorn app.main:create_app --factory --reload --host 127.0.0.1 --port 8080 --no-proxy-headers tournament-frontend-dev: npm --prefix apps/tournament run dev:client tournament-test: + $(UV) run --directory apps/tournament/backend --package crr-tournament --locked pytest npm --prefix apps/tournament test npm --prefix apps/tournament run typecheck + $(PYTHON) tools/check_tournament_compat.py + +tournament-compat-test: + $(UV) sync --package crr-tournament --locked + $(PYTHON) tools/check_tournament_compat.py tournament-build: npm --prefix apps/tournament run build diff --git a/README.md b/README.md index 165fadd..dc3cbe3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Two independently runnable applications migrating into one monorepo: -- [Tournament](apps/tournament/README.md): Vue 3 and the temporary legacy Node backend. +- [Tournament](apps/tournament/README.md): Vue 3 and FastAPI on Python 3.13. - [Shifts](apps/shifts/README.md): FastAPI, Jinja, SQLAlchemy and Alembic on Python 3.13. - [CRR brand](packages/crr-brand/README.md): shared logo, favicon and CSS primitives. @@ -20,8 +20,9 @@ make shifts-test make test ``` -`uv` resolves Shifts dependencies using the single root `uv.lock` and creates the -root `.venv`. Tournament joins that workspace when the FastAPI backend is ported. +`uv` resolves both backend projects using the single root `uv.lock` and creates +the root `.venv`. Each backend runs from its own directory and owns its database, +models, authentication and migration history. Run `make tournament-backend-dev` and `make tournament-frontend-dev` in separate terminals, or `make shifts-dev` for Shifts. App-specific environment setup is documented in each application's README. diff --git a/apps/shifts/Dockerfile b/apps/shifts/Dockerfile index af48f0d..094a7c5 100644 --- a/apps/shifts/Dockerfile +++ b/apps/shifts/Dockerfile @@ -5,6 +5,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 UV_PROJECT_ENVIRONMENT=/opt/ven WORKDIR /workspace COPY pyproject.toml uv.lock ./ COPY apps/shifts/backend/pyproject.toml apps/shifts/backend/pyproject.toml +COPY apps/tournament/backend/pyproject.toml apps/tournament/backend/pyproject.toml RUN uv sync --frozen --package crr-shifts --no-dev --no-editable COPY apps/shifts/backend/ apps/shifts/backend/ COPY packages/crr-brand/ packages/crr-brand/ diff --git a/apps/tournament/.env.example b/apps/tournament/.env.example index 40d8446..11d45da 100644 --- a/apps/tournament/.env.example +++ b/apps/tournament/.env.example @@ -1,5 +1,5 @@ - -ADMIN_PASSWORD= -SESSION_SECRET= -TRUST_PROXY=true -DEPLOYMENT_BASE_PATH=/jogo/ +ADMIN_PASSWORD=replace-with-an-admin-password +SESSION_SECRET=replace-with-a-long-random-session-secret +TRUST_PROXY=false +APP_BASE_PATH=/jogo/ +VITE_BASE_PATH=/jogo/ diff --git a/apps/tournament/AGENTS.md b/apps/tournament/AGENTS.md index f1759fd..2f20968 100644 --- a/apps/tournament/AGENTS.md +++ b/apps/tournament/AGENTS.md @@ -1,9 +1,15 @@ # Tournament - Follow the root migration plan and record checks in `migration/README.md`. -- Phase 1 temporarily retains the Vue/Fastify source layout and Node backend. +- The FastAPI backend is under `backend/app`; Vue stays under `src/client` until + final layout cleanup. Backend commands must run from `backend/` to isolate its + `app` package from Shifts. +- Alembic owns the schema. Validate legacy databases before stamping the baseline; + preserve SQLite rows, constraints, indexes, FKs and existing container UID 1000. - Preserve `/api/...`, camelCase payloads, Portuguese errors, session cookies, HMAC tokens, login limiting, SSE, and sub-path deployment during the Python port. - Keep deterministic calendar, standings, seeding, and bracket rules testable. - Run `make tournament-test` and `make tournament-build` from the repository root. +- The compatibility command reuses preserved legacy assertions against real Python + services and HTTP servers; production code must not import the legacy backend. - Version and release this application independently using `tournament-v...` tags. diff --git a/apps/tournament/Dockerfile b/apps/tournament/Dockerfile index 9b7be75..a907395 100644 --- a/apps/tournament/Dockerfile +++ b/apps/tournament/Dockerfile @@ -1,22 +1,26 @@ -# Phase 1 retains the legacy Node runtime until the FastAPI compatibility exit. -FROM node:22-alpine AS build +FROM node:22-alpine AS frontend WORKDIR /workspace/apps/tournament -RUN apk add --no-cache python3 make g++ COPY apps/tournament/package.json apps/tournament/package-lock.json ./ RUN npm ci COPY apps/tournament/ ./ COPY packages/crr-brand/ /workspace/packages/crr-brand/ ARG VITE_BASE_PATH=/jogo/ ENV VITE_BASE_PATH=${VITE_BASE_PATH} -RUN npm run build && npm prune --omit=dev +RUN npm run build -FROM node:22-alpine AS runtime -ENV NODE_ENV=production DATABASE_PATH=/data/tournament.sqlite CLIENT_DIST_PATH=/app/dist/client APP_BASE_PATH=/jogo/ -WORKDIR /app -COPY --from=build --chown=node:node /workspace/apps/tournament/package.json ./ -COPY --from=build --chown=node:node /workspace/apps/tournament/node_modules ./node_modules -COPY --from=build --chown=node:node /workspace/apps/tournament/dist ./dist -RUN mkdir /data && chown node:node /data -USER node +FROM ghcr.io/astral-sh/uv:0.11.31 AS uv +FROM python:3.13-slim AS runtime +COPY --from=uv /uv /uvx /bin/ +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 UV_PROJECT_ENVIRONMENT=/opt/venv PATH="/opt/venv/bin:$PATH" DATABASE_PATH=/data/tournament.sqlite APP_BASE_PATH=/jogo/ +WORKDIR /workspace +COPY pyproject.toml uv.lock ./ +COPY apps/tournament/backend/pyproject.toml apps/tournament/backend/pyproject.toml +COPY apps/shifts/backend/pyproject.toml apps/shifts/backend/pyproject.toml +RUN uv sync --frozen --package crr-tournament --no-dev --no-editable +COPY apps/tournament/backend/ apps/tournament/backend/ +COPY --from=frontend /workspace/apps/tournament/dist/client apps/tournament/dist/client/ +RUN groupadd --gid 1000 tournament && useradd --uid 1000 --gid 1000 --no-create-home tournament && mkdir /data && chown tournament /data +WORKDIR /workspace/apps/tournament/backend +USER tournament EXPOSE 8080 -CMD ["node", "dist/server/main.js"] +CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8080", "--no-proxy-headers"] diff --git a/apps/tournament/README.md b/apps/tournament/README.md index 92d2fd4..2c0a2cf 100644 --- a/apps/tournament/README.md +++ b/apps/tournament/README.md @@ -1,8 +1,10 @@ # Tournament -Phase 1 retains the legacy Vue/Fastify application under `src/client`, `src/server` -and `src/shared`. The planned `frontend/` and Python `backend/` split follows the -API compatibility port; the current backend remains independently runnable. +The FastAPI backend lives in `backend/app`, with app-specific SQLAlchemy models, +Alembic history, Pydantic schemas, domain services and routers. Vue continues to +use `src/client` and `src/shared` with its existing `/api/...` contract. The final +frontend directory move and removal of retired TypeScript server sources are +tracked as cleanup work in the migration plan. Shared CRR assets and CSS come from `packages/crr-brand` through Vite's `@crr-brand` alias. Tokens/base/components load before `src/client/styles.css`; @@ -21,7 +23,7 @@ make tournament-frontend-dev ``` Copy `apps/tournament/.env.example` to `apps/tournament/.env` and configure -`ADMIN_PASSWORD` and `SESSION_SECRET`. The backend listens on port 8080 and Vite +`ADMIN_PASSWORD` and `SESSION_SECRET`. Uvicorn listens on port 8080 and Vite on 5173. Development data is stored in `apps/tournament/tournament.sqlite`. Use consistent `APP_BASE_PATH` and `VITE_BASE_PATH`; `/jogo/` remains the example deployment prefix, while both support `/`. @@ -33,9 +35,27 @@ docker compose up --build tournament Compose loads `apps/tournament/.env`; `TOURNAMENT_BASE_PATH` in the root shell controls both the frontend build path and backend path (default `/jogo/`). The `tournament-data` volume contains `/data/tournament.sqlite`. The image is -`crr-tournament`; the current app version remains `1.0.0` in `package.json`. +`crr-tournament`; app version `1.0.0` is recorded in the frontend package and +backend project metadata. The image builds Vue with Node 22 and runs Python 3.13 +as UID/GID 1000, matching the old container's database ownership. -Run checks with Node 22+; the CI/container target is Node 22. The legacy package -has no lint script. Its tests cover HTTP/auth/SSE/sub-path behavior and the -deterministic tournament services. Python migration fixtures live in -`migration/fixtures/` at the repository root. +Tests require Node 22+ and uv. `make tournament-test` runs backend pytest, +frontend/shared tests, Vue typechecking and the preserved legacy compatibility +assertions against actual Python services and HTTP/SSE servers. Those assertions +live in `migration/contracts`; their transports are generated only in ignored +`.migration/` staging. CI uses Node 22/Python 3.13 and does not execute snapshots. +Node backend dependencies (Fastify, better-sqlite3 and Zod) have been removed. + +## Existing SQLite data + +Startup upgrades empty databases with Alembic. Pre-Alembic databases are validated +against all nine legacy tables, columns, defaults, checks, unique/index definitions +and foreign keys before being stamped at `0001_baseline`. Adoption preserves +their schema and rows; there is no export/import. An incompatible schema stops +startup before stamping. SQLite foreign keys, WAL and a 5000 ms busy timeout are +configured on every connection. Migration/stamp operations are transactional. + +Keep `SESSION_SECRET` unchanged during cut-over so existing HMAC/base64url admin +sessions remain valid. Cookie name/path, HttpOnly, Strict SameSite, HTTPS behavior +and seven-day lifetime are preserved. `TRUST_PROXY` controls forwarded headers. +Deployments under `/jogo/` retain the legacy proxy-prefix stripping behavior. diff --git a/apps/tournament/backend/alembic.ini b/apps/tournament/backend/alembic.ini new file mode 100644 index 0000000..8d81203 --- /dev/null +++ b/apps/tournament/backend/alembic.ini @@ -0,0 +1,4 @@ +[alembic] +script_location = %(here)s/migrations +prepend_sys_path = %(here)s +path_separator = os diff --git a/apps/tournament/backend/app/__init__.py b/apps/tournament/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tournament/backend/app/config.py b/apps/tournament/backend/app/config.py new file mode 100644 index 0000000..8079e43 --- /dev/null +++ b/apps/tournament/backend/app/config.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + admin_password: str = Field(min_length=1) + session_secret: str = Field(min_length=1) + database_path: str = "/data/tournament.sqlite" + app_base_path: str = "/" + client_dist_path: Path = Path(__file__).resolve().parents[2] / "dist/client" + trust_proxy: bool = False + session_lifetime_ms: int = 7 * 24 * 60 * 60 * 1000 + + model_config = SettingsConfigDict(env_file=Path(__file__).resolve().parents[2] / ".env", extra="ignore") + + @field_validator("app_base_path") + @classmethod + def normalize_base(cls, value: str) -> str: + if not value.startswith("/"): + raise ValueError('APP_BASE_PATH tem de começar por "/".') + return value if value.endswith("/") else value + "/" diff --git a/apps/tournament/backend/app/database.py b/apps/tournament/backend/app/database.py new file mode 100644 index 0000000..2ccbc9f --- /dev/null +++ b/apps/tournament/backend/app/database.py @@ -0,0 +1,100 @@ +import re +from pathlib import Path + +from alembic import command +from alembic.config import Config +from sqlalchemy import CheckConstraint, DefaultClause, UniqueConstraint, URL, create_engine, event, inspect +from sqlalchemy.engine import Connection, Engine +from sqlalchemy.pool import StaticPool + +from .models import Base + +BACKEND = Path(__file__).resolve().parents[1] + + +def create_database_engine(path: str) -> Engine: + if path != ":memory:": + Path(path).resolve().parent.mkdir(parents=True, exist_ok=True) + options = {"poolclass": StaticPool} if path == ":memory:" else {} + engine = create_engine(URL.create("sqlite", database=path), + connect_args={"check_same_thread": False}, **options) + + @event.listens_for(engine, "connect") + def pragmas(connection, _record): + cursor = connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.close() + + return engine + + +def normalized(value: object) -> str: + if value is None: + return "" + return re.sub(r"\s+", "", str(value)).lower() + + +def validate_legacy_schema(connection: Connection) -> None: + inspector = inspect(connection) + if set(inspector.get_table_names()) - {"alembic_version"} != set(Base.metadata.tables): + raise RuntimeError("Tournament database does not match the legacy table set; adoption refused.") + for name, table in Base.metadata.tables.items(): + actual = inspector.get_columns(name) + if [column["name"] for column in actual] != list(table.columns.keys()): + raise RuntimeError(f"Legacy columns differ: {name}; adoption refused.") + for column in actual: + expected = table.columns[column["name"]] + # SQLite reflects an INTEGER PRIMARY KEY as nullable, despite its implicit NOT NULL. + if (str(column["type"]) != str(expected.type).split(" COLLATE")[0] or + (not (expected.primary_key and len(table.primary_key.columns) == 1) and column["nullable"] != expected.nullable)): + raise RuntimeError(f"Legacy column type/nullability differs: {name}.{expected.name}") + default = column["default"] + expected_default = str(expected.server_default.arg) if isinstance(expected.server_default, DefaultClause) else None + if str(default).strip("'") != str(expected_default).strip("'"): + raise RuntimeError(f"Legacy default differs: {name}.{expected.name}") + if inspector.get_pk_constraint(name)["constrained_columns"] != [c.name for c in table.primary_key.columns]: + raise RuntimeError(f"Legacy primary key differs: {name}") + checks = {normalized(item["sqltext"]) for item in inspector.get_check_constraints(name)} + expected_checks = {normalized(item.sqltext) for item in table.constraints if isinstance(item, CheckConstraint)} + if checks != expected_checks: + raise RuntimeError(f"Legacy checks differ: {name}") + uniques = {tuple(item["column_names"]) for item in inspector.get_unique_constraints(name)} + expected_uniques = {tuple(c.name for c in item.columns) for item in table.constraints if isinstance(item, UniqueConstraint)} + if uniques != expected_uniques: + raise RuntimeError(f"Legacy unique constraints differ: {name}") + foreign = {(tuple(item["constrained_columns"]), item["referred_table"], tuple(item["referred_columns"]), + item.get("options", {}).get("ondelete")) for item in inspector.get_foreign_keys(name)} + expected_foreign = {(tuple(c.parent.name for c in item.elements), item.referred_table.name, + tuple(c.column.name for c in item.elements), item.ondelete) for item in table.foreign_key_constraints} + if foreign != expected_foreign: + raise RuntimeError(f"Legacy foreign keys differ: {name}") + indexes = {(item["name"], tuple(item["column_names"]), bool(item["unique"]), + normalized(item.get("dialect_options", {}).get("sqlite_where", ""))) + for item in inspector.get_indexes(name)} + expected_indexes = {(item.name, tuple(c.name for c in item.columns), item.unique, + normalized(item.dialect_options["sqlite"].get("where"))) for item in table.indexes} + if indexes != expected_indexes: + raise RuntimeError(f"Legacy indexes differ: {name}") + ddl = connection.exec_driver_sql("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (name,)).scalar_one() + if bool(table.dialect_options["sqlite"].get("autoincrement")) != ("autoincrement" in ddl.lower()): + raise RuntimeError(f"Legacy autoincrement differs: {name}") + if name == "league_groups" and not re.search(r"\bname\s+TEXT\b[^,]*\bCOLLATE\s+NOCASE\b", ddl, re.IGNORECASE): + raise RuntimeError("Legacy group collation differs") + if connection.exec_driver_sql("PRAGMA foreign_key_check").fetchall(): + raise RuntimeError("Legacy foreign key violations; adoption refused.") + + +def run_migrations(engine: Engine) -> None: + with engine.begin() as connection: + # SQLite's legacy driver transaction mode does not begin on DDL. + # Explicitly enclose validation, stamping and upgrade in one real transaction. + connection.exec_driver_sql("BEGIN IMMEDIATE") + config = Config(str(BACKEND / "alembic.ini")) + config.attributes["connection"] = connection + tables = set(inspect(connection).get_table_names()) + if tables and "alembic_version" not in tables: + validate_legacy_schema(connection) + command.stamp(config, "0001_baseline") + command.upgrade(config, "head") diff --git a/apps/tournament/backend/app/errors.py b/apps/tournament/backend/app/errors.py new file mode 100644 index 0000000..136f7fa --- /dev/null +++ b/apps/tournament/backend/app/errors.py @@ -0,0 +1,44 @@ +import logging + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from sqlalchemy.exc import IntegrityError +from starlette.exceptions import HTTPException + + +class AppError(Exception): + def __init__(self, status: int, message: str): + self.status = status + self.message = message + + +def install_errors(app: FastAPI) -> None: + @app.exception_handler(Exception) + async def unexpected_error(_request: Request, error: Exception): + logging.getLogger("tournament").error("Unexpected Tournament error", exc_info=error) + return JSONResponse({"error": "Ocorreu um erro inesperado. Tente novamente."}, status_code=500) + + @app.exception_handler(AppError) + async def domain_error(_request: Request, error: AppError): + return JSONResponse({"error": error.message}, status_code=error.status) + + @app.exception_handler(RequestValidationError) + async def validation_error(_request: Request, _error: RequestValidationError): + if any(item["type"] == "json_invalid" for item in _error.errors()): + # The legacy Fastify error handler treats malformed JSON as an unexpected error. + return JSONResponse({"error": "Ocorreu um erro inesperado. Tente novamente."}, status_code=500) + return JSONResponse({"error": "Os dados enviados não são válidos."}, status_code=400) + + @app.exception_handler(HTTPException) + async def http_error(_request: Request, error: HTTPException): + return JSONResponse({"error": "O recurso pedido não existe."}, status_code=404 if error.status_code in (404, 405) else error.status_code) + + @app.exception_handler(IntegrityError) + async def integrity_error(_request: Request, error: IntegrityError): + message = str(error.orig) + if "UNIQUE constraint failed: league_groups.name" in message: + return JSONResponse({"error": "Já existe um grupo com esse nome."}, status_code=409) + if "UNIQUE constraint failed: teams.number" in message: + return JSONResponse({"error": "Já existe uma equipa com esse número."}, status_code=409) + return JSONResponse({"error": "Ocorreu um erro inesperado. Tente novamente."}, status_code=500) diff --git a/apps/tournament/backend/app/events.py b/apps/tournament/backend/app/events.py new file mode 100644 index 0000000..b0fe065 --- /dev/null +++ b/apps/tournament/backend/app/events.py @@ -0,0 +1,42 @@ +import asyncio +from threading import Lock + +STATE_CHANGED = 'event: state-changed\ndata: {"type":"state-changed"}\n\n' + + +class StateChangeEvents: + def __init__(self, heartbeat_seconds: float = 25): + self.heartbeat_seconds = heartbeat_seconds + self.clients = {} + self.lock = Lock() + + def broadcast(self) -> None: + with self.lock: + for queue, loop in self.clients.items(): + loop.call_soon_threadsafe(queue.put_nowait, STATE_CHANGED) + + def close(self) -> None: + with self.lock: + for queue, loop in self.clients.items(): + loop.call_soon_threadsafe(queue.put_nowait, None) + + async def stream(self): + queue = asyncio.Queue() + loop = asyncio.get_running_loop() + with self.lock: + self.clients[queue] = loop + next_heartbeat = loop.time() + self.heartbeat_seconds + try: + yield "retry: 3000\n\n" + while True: + try: + message = await asyncio.wait_for(queue.get(), max(0, next_heartbeat - loop.time())) + if message is None: + return + yield message + except TimeoutError: + yield ": heartbeat\n\n" + next_heartbeat = loop.time() + self.heartbeat_seconds + finally: + with self.lock: + self.clients.pop(queue, None) diff --git a/apps/tournament/backend/app/main.py b/apps/tournament/backend/app/main.py new file mode 100644 index 0000000..6c08230 --- /dev/null +++ b/apps/tournament/backend/app/main.py @@ -0,0 +1,58 @@ +import asyncio +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.responses import FileResponse +from sqlalchemy.orm import sessionmaker +from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware + +from .config import Settings +from .database import create_database_engine, run_migrations +from .errors import AppError, install_errors +from .events import StateChangeEvents +from .routers import auth, public +from .routers.admin import calendar, display, final_stage, groups, matches, settings as settings_router, teams +from .security.rate_limit import LoginRateLimiter + + +def create_app(settings: Settings | None = None) -> FastAPI: + settings = settings or Settings() + engine = create_database_engine(settings.database_path) + events = StateChangeEvents() + + @asynccontextmanager + async def lifespan(app): + try: + run_migrations(engine) + yield + finally: + events.close() + engine.dispose() + + app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None, redirect_slashes=False) + app.state.settings = settings + app.state.engine = engine + app.state.sessions = sessionmaker(engine, expire_on_commit=False) + app.state.database_lock = asyncio.Lock() + app.state.events = events + app.state.limiter = LoginRateLimiter() + if settings.trust_proxy: + app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*") + install_errors(app) + for router in [auth.router, public.router, settings_router.router, display.router, groups.router, + teams.router, calendar.router, matches.router, final_stage.router]: + app.include_router(router) + + @app.api_route("/{path:path}", methods=["GET", "HEAD"], include_in_schema=False) + def client(path: str): + root = settings.client_dist_path.resolve() + file = (root / path).resolve() + if not file.is_relative_to(root): + raise AppError(404, "O recurso pedido não existe.") + if path in ("", "results", "display", "display/", "admin") or path.startswith("admin/"): + file = root / "index.html" + if not file.is_file(): + raise AppError(404, "O recurso pedido não existe.") + return FileResponse(file) + + return app diff --git a/apps/tournament/backend/app/models/__init__.py b/apps/tournament/backend/app/models/__init__.py new file mode 100644 index 0000000..4c6c0cf --- /dev/null +++ b/apps/tournament/backend/app/models/__init__.py @@ -0,0 +1,8 @@ +from .base import Base +from .final_stage import FinalResult, FinalSeed +from .matches import LeagueMatch, LeagueRound +from .settings import DisplaySettings, TournamentSettings +from .teams import Group, Player, Team + +__all__ = ["Base", "FinalResult", "FinalSeed", "LeagueMatch", "LeagueRound", + "DisplaySettings", "TournamentSettings", "Group", "Player", "Team"] diff --git a/apps/tournament/backend/app/models/base.py b/apps/tournament/backend/app/models/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/apps/tournament/backend/app/models/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/apps/tournament/backend/app/models/final_stage.py b/apps/tournament/backend/app/models/final_stage.py new file mode 100644 index 0000000..66ea601 --- /dev/null +++ b/apps/tournament/backend/app/models/final_stage.py @@ -0,0 +1,24 @@ +from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, text +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class FinalSeed(Base): + __tablename__ = "final_seeds" + __table_args__ = (CheckConstraint("slot_index >= 1"), + Index("idx_final_seeds_unique_team", "team_id", unique=True, sqlite_where=text("team_id IS NOT NULL"))) + slot_index: Mapped[int] = mapped_column(Integer, primary_key=True) + team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id")) + + +class FinalResult(Base): + __tablename__ = "final_match_results" + __table_args__ = ( + CheckConstraint("round_index >= 1"), CheckConstraint("match_index >= 1"), + CheckConstraint("(score_a IS NULL AND score_b IS NULL) OR (score_a IS NOT NULL AND score_b IS NOT NULL AND score_a >= 0 AND score_b >= 0)"), + ) + round_index: Mapped[int] = mapped_column(Integer, primary_key=True) + match_index: Mapped[int] = mapped_column(Integer, primary_key=True) + score_a: Mapped[int | None] = mapped_column(Integer) + score_b: Mapped[int | None] = mapped_column(Integer) diff --git a/apps/tournament/backend/app/models/matches.py b/apps/tournament/backend/app/models/matches.py new file mode 100644 index 0000000..65fec11 --- /dev/null +++ b/apps/tournament/backend/app/models/matches.py @@ -0,0 +1,28 @@ +from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class LeagueMatch(Base): + __tablename__ = "league_matches" + __table_args__ = ( + CheckConstraint("order_index >= 1"), CheckConstraint("round_index >= 1"), + CheckConstraint("team_a_id <> team_b_id"), + CheckConstraint("(score_a IS NULL AND score_b IS NULL) OR (score_a IS NOT NULL AND score_b IS NOT NULL AND score_a >= 0 AND score_b >= 0)"), + Index("idx_league_matches_order", "order_index"), {"sqlite_autoincrement": True}, + ) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + order_index: Mapped[int] = mapped_column(Integer, unique=True) + round_index: Mapped[int] = mapped_column(Integer) + team_a_id: Mapped[int] = mapped_column(ForeignKey("teams.id")) + team_b_id: Mapped[int] = mapped_column(ForeignKey("teams.id")) + score_a: Mapped[int | None] = mapped_column(Integer) + score_b: Mapped[int | None] = mapped_column(Integer) + + +class LeagueRound(Base): + __tablename__ = "league_rounds" + __table_args__ = (CheckConstraint("round_index >= 1"), CheckConstraint("counts_toward_standings IN (0, 1)")) + round_index: Mapped[int] = mapped_column(Integer, primary_key=True) + counts_toward_standings: Mapped[int] = mapped_column(Integer, server_default="0") diff --git a/apps/tournament/backend/app/models/settings.py b/apps/tournament/backend/app/models/settings.py new file mode 100644 index 0000000..da440d3 --- /dev/null +++ b/apps/tournament/backend/app/models/settings.py @@ -0,0 +1,34 @@ +from sqlalchemy import CheckConstraint, Integer, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class TournamentSettings(Base): + __tablename__ = "tournament_settings" + __table_args__ = ( + CheckConstraint("id = 1"), CheckConstraint("length(trim(name)) > 0"), + CheckConstraint("final_round_count IS NULL OR (final_round_count BETWEEN 1 AND 8)"), + CheckConstraint("third_place_enabled IN (0, 1)"), + CheckConstraint("classification_mode IN ('standard', 'total-points')"), + ) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(Text) + final_round_count: Mapped[int | None] = mapped_column(Integer) + created_at: Mapped[str] = mapped_column(Text, default=func.current_timestamp()) + updated_at: Mapped[str] = mapped_column(Text, default=func.current_timestamp()) + third_place_enabled: Mapped[int] = mapped_column(Integer, server_default="0") + classification_mode: Mapped[str] = mapped_column(Text, server_default="standard") + + +class DisplaySettings(Base): + __tablename__ = "display_settings" + __table_args__ = ( + CheckConstraint("id = 1"), CheckConstraint("length(trim(active_panel)) > 0"), + CheckConstraint("zoom_percent BETWEEN 50 AND 400"), + ) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + active_panel: Mapped[str] = mapped_column(Text) + zoom_percent: Mapped[int] = mapped_column(Integer, server_default="100") + created_at: Mapped[str] = mapped_column(Text, default=func.current_timestamp()) + updated_at: Mapped[str] = mapped_column(Text, default=func.current_timestamp()) diff --git a/apps/tournament/backend/app/models/teams.py b/apps/tournament/backend/app/models/teams.py new file mode 100644 index 0000000..f46b296 --- /dev/null +++ b/apps/tournament/backend/app/models/teams.py @@ -0,0 +1,37 @@ +from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class Group(Base): + __tablename__ = "league_groups" + __table_args__ = (CheckConstraint("length(trim(name)) > 0"), CheckConstraint("sort_order >= 0"), + {"sqlite_autoincrement": True}) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(Text(collation="NOCASE"), unique=True) + sort_order: Mapped[int] = mapped_column(Integer, unique=True) + created_at: Mapped[str] = mapped_column(Text, default=func.current_timestamp()) + updated_at: Mapped[str] = mapped_column(Text, default=func.current_timestamp()) + + +class Team(Base): + __tablename__ = "teams" + __table_args__ = (CheckConstraint("number > 0"), CheckConstraint("length(trim(name)) > 0"), + Index("idx_teams_group", "group_id"), {"sqlite_autoincrement": True}) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + number: Mapped[int] = mapped_column(Integer, unique=True) + name: Mapped[str] = mapped_column(Text) + group_id: Mapped[int] = mapped_column(ForeignKey("league_groups.id")) + created_at: Mapped[str] = mapped_column(Text, default=func.current_timestamp()) + updated_at: Mapped[str] = mapped_column(Text, default=func.current_timestamp()) + + +class Player(Base): + __tablename__ = "players" + __table_args__ = (CheckConstraint("length(trim(name)) > 0"), CheckConstraint("sort_order >= 0"), + Index("idx_players_team_order", "team_id", "sort_order"), {"sqlite_autoincrement": True}) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + team_id: Mapped[int] = mapped_column(ForeignKey("teams.id", ondelete="CASCADE")) + name: Mapped[str] = mapped_column(Text) + sort_order: Mapped[int] = mapped_column(Integer) diff --git a/apps/tournament/backend/app/routers/__init__.py b/apps/tournament/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tournament/backend/app/routers/admin/__init__.py b/apps/tournament/backend/app/routers/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tournament/backend/app/routers/admin/calendar.py b/apps/tournament/backend/app/routers/admin/calendar.py new file mode 100644 index 0000000..1a71119 --- /dev/null +++ b/apps/tournament/backend/app/routers/admin/calendar.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, Depends, Request +from sqlalchemy import select + +from ...models import LeagueMatch +from ...schemas.base import Confirmation +from ...schemas.calendar import GenerationInput, PreviewInput +from ...services import calendar_store +from ...services.state import get_matches +from ..dependencies import DB, changed, confirm, require_admin + +router = APIRouter(prefix="/api/admin/calendar", dependencies=[Depends(require_admin)]) + + +@router.get("") +@router.head("", include_in_schema=False) +def read_calendar(db: DB): + return calendar_store.summary(get_matches(db)) + + +@router.post("/generate-preview") +def preview(body: PreviewInput, db: DB): + return calendar_store.generate_preview(db, body.legs) + + +@router.post("/generate") +def generate(body: GenerationInput, db: DB, request: Request): + result = calendar_store.save_calendar(db, body) + changed(db, request) + return result + + +@router.delete("") +def clear(db: DB, request: Request, body: Confirmation | None = None): + if db.scalar(select(LeagueMatch.id).limit(1)) is not None: + confirm(body is not None and body.confirm, "Confirme que pretende limpar o calendário e todos os seus resultados.") + calendar_store.clear_calendar(db) + changed(db, request) + return {"ok": True} diff --git a/apps/tournament/backend/app/routers/admin/display.py b/apps/tournament/backend/app/routers/admin/display.py new file mode 100644 index 0000000..36bc889 --- /dev/null +++ b/apps/tournament/backend/app/routers/admin/display.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends, Request +from sqlalchemy import func + +from ...models import DisplaySettings as DisplayRow +from ...schemas.settings import DisplayInput +from ...services.state import get_display +from ..dependencies import DB, changed, require_admin + +router = APIRouter(prefix="/api/admin/display", dependencies=[Depends(require_admin)]) + + +@router.get("") +@router.head("", include_in_schema=False) +def read_display(db: DB): + return get_display(db) + + +@router.put("") +def update_display(body: DisplayInput, db: DB, request: Request): + row = db.get(DisplayRow, 1) + row.active_panel, row.zoom_percent, row.updated_at = body.active_panel, body.zoom_percent, func.current_timestamp() + changed(db, request) + return get_display(db) diff --git a/apps/tournament/backend/app/routers/admin/final_stage.py b/apps/tournament/backend/app/routers/admin/final_stage.py new file mode 100644 index 0000000..f35abf7 --- /dev/null +++ b/apps/tournament/backend/app/routers/admin/final_stage.py @@ -0,0 +1,60 @@ +from typing import Annotated + +from fastapi import APIRouter, Body, Depends, Request + +from ...schemas.base import Confirmation, PositiveId +from ...schemas.calendar import ResultInput +from ...schemas.final_stage import ConfigInput, OverallInput, PerGroupInput, SeedsInput +from ...services import final_stage +from ...services.state import get_final_stage, get_settings +from ..dependencies import DB, changed, confirm, require_admin + +router = APIRouter(prefix="/api/admin/final-stage", dependencies=[Depends(require_admin)]) + + +@router.get("") +@router.head("", include_in_schema=False) +def read_stage(db: DB): + return get_final_stage(db) + + +@router.put("/config") +def configure(body: ConfigInput, db: DB, request: Request): + if final_stage.configure(db, body): + changed(db, request) + return get_final_stage(db) + + +@router.delete("/config") +def clear_config(db: DB, request: Request, body: Confirmation | None = None): + if get_settings(db).final_round_count is not None: + confirm(body is not None and body.confirm, "Confirme que pretende limpar a configuração e todos os resultados da fase final.") + final_stage.clear(db) + changed(db, request) + return get_final_stage(db) + + +@router.post("/auto-seed-preview") +def preview(body: Annotated[PerGroupInput | OverallInput, Body(discriminator="mode")], db: DB): + return final_stage.seed_preview(db, body) + + +@router.put("/seeds") +def seeds(body: SeedsInput, db: DB, request: Request): + if final_stage.save_seeds(db, body): + changed(db, request) + return get_final_stage(db) + + +@router.put("/matches/{round}/{match}/result") +def save_result(round: PositiveId, match: PositiveId, body: ResultInput, db: DB, request: Request): + final_stage.save_result(db, round, match, body.score_a, body.score_b) + changed(db, request) + return get_final_stage(db) + + +@router.delete("/matches/{round}/{match}/result") +def clear_result(round: PositiveId, match: PositiveId, db: DB, request: Request): + final_stage.clear_result(db, round, match) + changed(db, request) + return get_final_stage(db) diff --git a/apps/tournament/backend/app/routers/admin/groups.py b/apps/tournament/backend/app/routers/admin/groups.py new file mode 100644 index 0000000..b8c0061 --- /dev/null +++ b/apps/tournament/backend/app/routers/admin/groups.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter, Depends, Request + +from ...schemas.base import PositiveId +from ...schemas.teams import NameInput +from ...services import teams +from ...services.state import get_groups +from ..dependencies import DB, changed, require_admin + +router = APIRouter(prefix="/api/admin/groups", dependencies=[Depends(require_admin)]) + + +@router.get("") +@router.head("", include_in_schema=False) +def read_groups(db: DB): + return get_groups(db) + + +@router.post("", status_code=201) +def create_group(body: NameInput, db: DB, request: Request): + result = teams.create_group(db, body.name) + changed(db, request) + return result + + +@router.put("/{id}") +def update_group(id: PositiveId, body: NameInput, db: DB, request: Request): + result = teams.update_group(db, id, body.name) + changed(db, request) + return result + + +@router.delete("/{id}") +def delete_group(id: PositiveId, db: DB, request: Request): + teams.delete_group(db, id) + changed(db, request) + return {"ok": True} diff --git a/apps/tournament/backend/app/routers/admin/matches.py b/apps/tournament/backend/app/routers/admin/matches.py new file mode 100644 index 0000000..ecc749c --- /dev/null +++ b/apps/tournament/backend/app/routers/admin/matches.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter, Depends, Request + +from ...errors import AppError +from ...models import LeagueMatch, LeagueRound +from ...schemas.base import PositiveId +from ...schemas.calendar import ResultInput, StandingsInput +from ...services.state import get_matches +from ..dependencies import DB, changed, require_admin + +router = APIRouter(prefix="/api/admin", dependencies=[Depends(require_admin)]) + + +def existing_match(db, identifier): + row = db.get(LeagueMatch, identifier) + if row is None: + raise AppError(404, "O jogo não existe.") + return row + + +@router.put("/matches/{id}/result") +def save_result(id: PositiveId, body: ResultInput, db: DB, request: Request): + row = existing_match(db, id) + row.score_a, row.score_b = body.score_a, body.score_b + changed(db, request) + return next(match for match in get_matches(db) if match.id == id) + + +@router.delete("/matches/{id}/result") +def clear_result(id: PositiveId, db: DB, request: Request): + row = existing_match(db, id) + row.score_a = row.score_b = None + changed(db, request) + return {"ok": True} + + +@router.put("/rounds/{round}/standings") +def save_standings(round: PositiveId, body: StandingsInput, db: DB, request: Request): + row = db.get(LeagueRound, round) + if row is None: + raise AppError(404, "A jornada não existe.") + row.counts_toward_standings = int(body.counts_toward_standings) + changed(db, request) + return {"roundNumber": round, "countsTowardStandings": body.counts_toward_standings} diff --git a/apps/tournament/backend/app/routers/admin/settings.py b/apps/tournament/backend/app/routers/admin/settings.py new file mode 100644 index 0000000..e1709bc --- /dev/null +++ b/apps/tournament/backend/app/routers/admin/settings.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends, Request +from sqlalchemy import func + +from ...models import TournamentSettings +from ...schemas.settings import SettingsInput +from ...services.state import get_settings +from ..dependencies import DB, changed, require_admin + +router = APIRouter(prefix="/api/admin/settings", dependencies=[Depends(require_admin)]) + + +@router.get("") +@router.head("", include_in_schema=False) +def read_settings(db: DB): + return get_settings(db) + + +@router.put("") +def update_settings(body: SettingsInput, db: DB, request: Request): + row = db.get(TournamentSettings, 1) + row.name, row.classification_mode, row.updated_at = body.name, body.classification_mode, func.current_timestamp() + changed(db, request) + return get_settings(db) diff --git a/apps/tournament/backend/app/routers/admin/teams.py b/apps/tournament/backend/app/routers/admin/teams.py new file mode 100644 index 0000000..9e96347 --- /dev/null +++ b/apps/tournament/backend/app/routers/admin/teams.py @@ -0,0 +1,82 @@ +from fastapi import APIRouter, Depends, Request +from sqlalchemy import func, select + +from ...models import Player +from ...errors import AppError +from ...schemas.base import Confirmation, PositiveId +from ...schemas.teams import NameInput, TeamCreate, TeamInput +from ...services import teams +from ...services.state import get_admin_teams, player_from_row +from ..dependencies import DB, changed, confirm, require_admin + +router = APIRouter(prefix="/api/admin", dependencies=[Depends(require_admin)]) + + +@router.get("/teams") +@router.head("/teams", include_in_schema=False) +def read_teams(db: DB): + return get_admin_teams(db) + + +@router.post("/teams/randomize") +def randomize(db: DB, request: Request, body: Confirmation | None = None): + confirm(body is not None and body.confirm, "Confirme que pretende randomizar os números e os grupos das equipas.") + result = teams.randomize_teams(db) + changed(db, request) + return result + + +@router.post("/teams", status_code=201) +def create_team(body: TeamCreate, db: DB, request: Request): + result = teams.create_team(db, body) + changed(db, request) + return result + + +@router.put("/teams/{id}") +def update_team(id: PositiveId, body: TeamInput, db: DB, request: Request): + result = teams.update_team(db, id, body) + changed(db, request) + return result + + +@router.delete("/teams/{id}") +def delete_team(id: PositiveId, db: DB, request: Request): + teams.delete_team(db, id) + changed(db, request) + return {"ok": True} + + +@router.post("/teams/{teamId}/players", status_code=201) +def create_player(teamId: PositiveId, body: NameInput, db: DB, request: Request): + teams.existing_team(db, teamId) + order = db.scalar(select(func.max(Player.sort_order)).where(Player.team_id == teamId)) + row = Player(team_id=teamId, name=body.name, sort_order=0 if order is None else order + 1) + db.add(row) + db.flush() + result = player_from_row(row) + changed(db, request) + return result + + +def existing_player(db, identifier): + row = db.get(Player, identifier) + if row is None: + raise AppError(404, "O jogador não existe.") + return row + + +@router.put("/players/{id}") +def update_player(id: PositiveId, body: NameInput, db: DB, request: Request): + row = existing_player(db, id) + row.name = body.name + result = player_from_row(row) + changed(db, request) + return result + + +@router.delete("/players/{id}") +def delete_player(id: PositiveId, db: DB, request: Request): + db.delete(existing_player(db, id)) + changed(db, request) + return {"ok": True} diff --git a/apps/tournament/backend/app/routers/auth.py b/apps/tournament/backend/app/routers/auth.py new file mode 100644 index 0000000..5fe3669 --- /dev/null +++ b/apps/tournament/backend/app/routers/auth.py @@ -0,0 +1,54 @@ +from fastapi import APIRouter, Request, Response +from pydantic import Field, field_validator + +from ..errors import AppError +from ..schemas.base import RequestSchema +from ..security.session import COOKIE_NAME, create_token, password_matches, valid_token + +router = APIRouter(prefix="/api/auth") + + +class LoginInput(RequestSchema): + password: str = Field(strict=True, min_length=1, max_length=1000) + + @field_validator("password") + @classmethod + def password_length(cls, value): + if len(value.encode("utf-16-le", errors="surrogatepass")) // 2 > 1000: + raise ValueError("Invalid password length") + return value + + +@router.post("/login") +def login(body: LoginInput, request: Request, response: Response): + # Checking, recording and resetting attempts must be atomic across ASGI workers. + with request.app.state.limiter.lock: + return finish_login(body, request, response) + + +def finish_login(body: LoginInput, request: Request, response: Response): + settings, limiter = request.app.state.settings, request.app.state.limiter + key = request.client.host if request.client else "" + if limiter.limited(key): + raise AppError(429, "Foram feitas demasiadas tentativas. Tente novamente mais tarde.") + if not password_matches(body.password, settings.admin_password): + limiter.failure(key) + raise AppError(401, "A palavra-passe está incorreta.") + limiter.clear(key) + response.set_cookie(COOKIE_NAME, create_token(settings), path=settings.app_base_path, + httponly=True, samesite="strict", secure=request.url.scheme == "https", + max_age=settings.session_lifetime_ms // 1000) + return {"authenticated": True} + + +@router.post("/logout") +def logout(request: Request, response: Response): + response.delete_cookie(COOKIE_NAME, path=request.app.state.settings.app_base_path, + httponly=True, samesite="strict", secure=request.url.scheme == "https") + return {"authenticated": False} + + +@router.get("/session") +@router.head("/session", include_in_schema=False) +def session(request: Request): + return {"authenticated": valid_token(request.cookies.get(COOKIE_NAME), request.app.state.settings)} diff --git a/apps/tournament/backend/app/routers/dependencies.py b/apps/tournament/backend/app/routers/dependencies.py new file mode 100644 index 0000000..e9c3ac8 --- /dev/null +++ b/apps/tournament/backend/app/routers/dependencies.py @@ -0,0 +1,33 @@ +from typing import Annotated + +from fastapi import Depends, Request +from sqlalchemy.orm import Session + +from ..errors import AppError +from ..security.session import COOKIE_NAME, valid_token + + +async def get_db(request: Request): + # The Node backend executed each synchronous SQLite operation serially. + # Preserve that behavior for read/modify/write operations such as automatic numbering. + async with request.app.state.database_lock: + with request.app.state.sessions() as db: + yield db + + +DB = Annotated[Session, Depends(get_db)] + + +def require_admin(request: Request) -> None: + if not valid_token(request.cookies.get(COOKIE_NAME), request.app.state.settings): + raise AppError(401, "É necessário iniciar sessão para aceder à administração.") + + +def changed(db: Session, request: Request) -> None: + db.commit() + request.app.state.events.broadcast() + + +def confirm(value: bool, message: str) -> None: + if not value: + raise AppError(409, message) diff --git a/apps/tournament/backend/app/routers/public.py b/apps/tournament/backend/app/routers/public.py new file mode 100644 index 0000000..3587925 --- /dev/null +++ b/apps/tournament/backend/app/routers/public.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse + +from ..services.state import get_public_state +from .dependencies import DB + +router = APIRouter(prefix="/api/public") + + +@router.get("/state") +@router.head("/state", include_in_schema=False) +def state(db: DB): + return get_public_state(db) + + +@router.get("/events") +@router.head("/events", include_in_schema=False) +async def events(request: Request): + return StreamingResponse(request.app.state.events.stream(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", "X-Accel-Buffering": "no"}) diff --git a/apps/tournament/backend/app/schemas/__init__.py b/apps/tournament/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tournament/backend/app/schemas/base.py b/apps/tournament/backend/app/schemas/base.py new file mode 100644 index 0000000..1d5ebf4 --- /dev/null +++ b/apps/tournament/backend/app/schemas/base.py @@ -0,0 +1,64 @@ +from typing import Annotated + +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StringConstraints +from pydantic.alias_generators import to_camel + +MAX_SAFE_INTEGER = 9007199254740991 + + +class Schema(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra="ignore") + + +class RequestSchema(Schema): + model_config = ConfigDict(populate_by_name=False, validate_by_name=False, validate_by_alias=True) + + +def number(value): + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError("Expected a JSON number") + return value + + +def identifier(value): + # Zod's coerce.number() is used for URL IDs and team group IDs in the legacy app. + try: + if isinstance(value, str): + value = value.strip() + if value.lower().startswith(("0x", "0b", "0o")): + return int(value, 0) + return float(value or "0") + return int(value) if isinstance(value, bool) else value + except (ValueError, OverflowError): + raise ValueError("Invalid identifier") from None + + +PositiveInt = Annotated[int, BeforeValidator(number), Field(ge=1, le=MAX_SAFE_INTEGER)] +PositiveId = Annotated[int, BeforeValidator(identifier), Field(ge=1, le=MAX_SAFE_INTEGER)] +Score = Annotated[int, BeforeValidator(number), Field(ge=0, le=MAX_SAFE_INTEGER)] +JS_WHITESPACE = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff" + + +def name(value): + if not isinstance(value, str): + raise ValueError("Expected a string") + value = value.strip(JS_WHITESPACE) + if not 1 <= len(value.encode("utf-16-le", errors="surrogatepass")) // 2 <= 200: + raise ValueError("Invalid name length") + return value + + +Name = Annotated[str, BeforeValidator(name), StringConstraints(strict=True, min_length=1, max_length=200)] + + +def confirmed(value): + if value is not True: + raise ValueError("Confirmation must be true") + return value + + +Confirmed = Annotated[bool, BeforeValidator(confirmed)] + + +class Confirmation(RequestSchema): + confirm: Confirmed = False diff --git a/apps/tournament/backend/app/schemas/calendar.py b/apps/tournament/backend/app/schemas/calendar.py new file mode 100644 index 0000000..9527b37 --- /dev/null +++ b/apps/tournament/backend/app/schemas/calendar.py @@ -0,0 +1,51 @@ +from typing import Annotated, Literal + +from pydantic import BeforeValidator, Field + +from .base import Confirmed, PositiveId, RequestSchema, Schema, Score, number +from .teams import Group, TeamSummary + +Legs = Annotated[Literal[1, 2], BeforeValidator(number)] + + +class GenerationTeam(RequestSchema): + team_id: PositiveId + group_id: PositiveId + + +class Generation(RequestSchema): + legs: Legs + team_order: list[GenerationTeam] + + +class GenerationInput(RequestSchema): + generation: Generation + confirm_replace: Confirmed = False + + +class PreviewInput(RequestSchema): + legs: Legs + + +class CalendarMatch(Schema): + game_number: int + round_number: int + group: Group | None + team_a: TeamSummary + team_b: TeamSummary + + +class LeagueMatch(CalendarMatch): + id: int + counts_toward_standings: bool + score_a: int | None + score_b: int | None + + +class ResultInput(RequestSchema): + score_a: Score + score_b: Score + + +class StandingsInput(RequestSchema): + counts_toward_standings: Annotated[bool, Field(strict=True)] diff --git a/apps/tournament/backend/app/schemas/final_stage.py b/apps/tournament/backend/app/schemas/final_stage.py new file mode 100644 index 0000000..a3f7ec3 --- /dev/null +++ b/apps/tournament/backend/app/schemas/final_stage.py @@ -0,0 +1,79 @@ +from typing import Annotated, Literal + +from pydantic import Field + +from .base import Confirmed, PositiveInt, RequestSchema, Schema +from .teams import TeamSummary + + +class Participant(Schema): + team: TeamSummary | None + pending: bool = False + + +class BracketMatch(Schema): + match_index: int + team_a: Participant + team_b: Participant + score_a: int | None = None + score_b: int | None = None + winner: TeamSummary | None = None + is_bye: bool = False + + +class BracketRound(Schema): + round_index: int + name: str + matches: list[BracketMatch] + + +class FinalStage(Schema): + round_count: int | None + third_place_enabled: bool = False + rounds: list[BracketRound] = Field(default_factory=list) + third_place_match: BracketMatch | None = None + champion: TeamSummary | None = None + + +class ConfigInput(RequestSchema): + round_count: Annotated[PositiveInt, Field(le=8)] + third_place_enabled: Annotated[bool, Field(strict=True)] = False + confirm_clear_results: Confirmed = False + + +class Seed(Schema): + slot_index: PositiveInt + team_id: PositiveInt | None + + +class SeedInput(Seed, RequestSchema): + pass + + +class SeedsInput(RequestSchema): + seeds: list[SeedInput] + confirm_clear_results: Confirmed = False + + +class PerGroupInput(RequestSchema): + mode: Literal["per-group"] + qualifiers_per_group: PositiveInt + + +class OverallInput(RequestSchema): + mode: Literal["overall"] + qualifier_count: PositiveInt + + +class QualifiedTeam(Schema): + seed_number: int + group_position: int + team: TeamSummary + + +class SeedPreview(Schema): + mode: Literal["per-group", "overall"] + qualifiers_per_group: int | None + qualifier_count: int + qualifiers: list[QualifiedTeam] + seeds: list[Seed] diff --git a/apps/tournament/backend/app/schemas/settings.py b/apps/tournament/backend/app/schemas/settings.py new file mode 100644 index 0000000..fc79fe8 --- /dev/null +++ b/apps/tournament/backend/app/schemas/settings.py @@ -0,0 +1,29 @@ +from typing import Annotated, Literal + +from pydantic import BeforeValidator, ConfigDict, Field + +from .base import Name, RequestSchema, Schema, number + +ClassificationMode = Literal["standard", "total-points"] +DisplayPanel = Literal["next-match", "latest-results", "classification"] + + +class SettingsInput(RequestSchema): + name: Name + classification_mode: ClassificationMode + + +class TournamentSettings(Schema): + name: str + classification_mode: ClassificationMode + final_round_count: int | None + third_place_enabled: bool + + +class DisplaySettings(Schema): + active_panel: DisplayPanel + zoom_percent: Annotated[int, BeforeValidator(number), Field(ge=50, le=400)] + + +class DisplayInput(DisplaySettings): + model_config = ConfigDict(populate_by_name=False, validate_by_name=False, validate_by_alias=True) diff --git a/apps/tournament/backend/app/schemas/state.py b/apps/tournament/backend/app/schemas/state.py new file mode 100644 index 0000000..ba3be6c --- /dev/null +++ b/apps/tournament/backend/app/schemas/state.py @@ -0,0 +1,32 @@ +from .base import Schema +from .calendar import LeagueMatch +from .final_stage import FinalStage +from .settings import DisplaySettings, TournamentSettings +from .teams import Group, TeamSummary + + +class ClassificationRow(Schema): + position: int = 0 + team: TeamSummary + played: int = 0 + wins: int = 0 + draws: int = 0 + losses: int = 0 + goals_for: int = 0 + goals_against: int = 0 + goal_difference: int = 0 + points: int = 0 + + +class GroupStanding(Schema): + group: Group + rows: list[ClassificationRow] + + +class PublicState(Schema): + tournament: TournamentSettings + display: DisplaySettings + groups: list[Group] + matches: list[LeagueMatch] + group_standings: list[GroupStanding] + final_stage: FinalStage diff --git a/apps/tournament/backend/app/schemas/teams.py b/apps/tournament/backend/app/schemas/teams.py new file mode 100644 index 0000000..7a5fa5d --- /dev/null +++ b/apps/tournament/backend/app/schemas/teams.py @@ -0,0 +1,48 @@ +from pydantic import Field, field_validator + +from .base import Name, PositiveId, PositiveInt, RequestSchema, Schema + + +class Group(Schema): + id: int + name: str + sort_order: int + + +class TeamSummary(Schema): + id: int + number: int + name: str + group: Group + + +class Player(Schema): + id: int + team_id: int + name: str + sort_order: int + + +class Team(TeamSummary): + players: list[Player] = Field(default_factory=list) + + +class NameInput(RequestSchema): + name: Name + + +class TeamInput(NameInput): + number: PositiveInt + group_id: PositiveId + + +class TeamCreate(NameInput): + number: PositiveInt | None = None + group_id: PositiveId + + @field_validator("number", mode="before") + @classmethod + def reject_explicit_null(cls, value): + if value is None: + raise ValueError("number must be omitted or positive") + return value diff --git a/apps/tournament/backend/app/security/__init__.py b/apps/tournament/backend/app/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tournament/backend/app/security/rate_limit.py b/apps/tournament/backend/app/security/rate_limit.py new file mode 100644 index 0000000..d8a0f3e --- /dev/null +++ b/apps/tournament/backend/app/security/rate_limit.py @@ -0,0 +1,30 @@ +from threading import RLock + +from .session import timestamp_ms + + +class LoginRateLimiter: + def __init__(self, maximum_failures: int = 5, window_ms: int = 15 * 60 * 1000): + self.maximum_failures = maximum_failures + self.window_ms = window_ms + self.attempts: dict[str, tuple[int, int]] = {} + self.lock = RLock() + + def limited(self, key: str, now: int | None = None) -> bool: + now = timestamp_ms() if now is None else now + with self.lock: + for expired in [key for key, (_, reset) in self.attempts.items() if reset <= now]: + del self.attempts[expired] + return self.attempts.get(key, (0, 0))[0] >= self.maximum_failures + + def failure(self, key: str, now: int | None = None) -> None: + now = timestamp_ms() if now is None else now + with self.lock: + count, reset = self.attempts.get(key, (0, now + self.window_ms)) + if reset <= now: + count, reset = 0, now + self.window_ms + self.attempts[key] = (count + 1, reset) + + def clear(self, key: str) -> None: + with self.lock: + self.attempts.pop(key, None) diff --git a/apps/tournament/backend/app/security/session.py b/apps/tournament/backend/app/security/session.py new file mode 100644 index 0000000..48528a6 --- /dev/null +++ b/apps/tournament/backend/app/security/session.py @@ -0,0 +1,46 @@ +import base64 +import hashlib +import hmac +import json +import math +import time + +from ..config import Settings + +COOKIE_NAME = "tournament_admin_session" + + +def timestamp_ms() -> int: + return int(time.time() * 1000) + + +def encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +def sign(payload: str, secret: str) -> str: + return encode(hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest()) + + +def create_token(settings: Settings, now: int | None = None) -> str: + expiry = (timestamp_ms() if now is None else now) + settings.session_lifetime_ms + payload = encode(json.dumps({"expiresAt": expiry}, separators=(",", ":")).encode()) + return payload + "." + sign(payload, settings.session_secret) + + +def valid_token(token: str | None, settings: Settings, now: int | None = None) -> bool: + if not token or token.count(".") != 1: + return False + payload, signature = token.split(".") + if not payload or not hmac.compare_digest(signature.encode(), sign(payload, settings.session_secret).encode()): + return False + try: + value = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) + expiry = value.get("expiresAt") + return type(expiry) in (int, float) and math.isfinite(expiry) and expiry > (timestamp_ms() if now is None else now) + except (ValueError, TypeError, AttributeError): + return False + + +def password_matches(actual: str, expected: str) -> bool: + return hmac.compare_digest(actual.encode(), expected.encode()) diff --git a/apps/tournament/backend/app/services/__init__.py b/apps/tournament/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tournament/backend/app/services/bracket.py b/apps/tournament/backend/app/services/bracket.py new file mode 100644 index 0000000..54f738c --- /dev/null +++ b/apps/tournament/backend/app/services/bracket.py @@ -0,0 +1,70 @@ +from ..schemas.final_stage import BracketMatch, BracketRound, FinalStage, Participant +from ..schemas.teams import TeamSummary + + +def calculate_bracket(round_count: int | None, seeds: dict[int, TeamSummary | None], + results: dict[tuple[int, int], tuple[int | None, int | None]], + third_place_enabled: bool = False) -> FinalStage: + if round_count is None: + return FinalStage(round_count=None) + if type(round_count) is not int or not 1 <= round_count <= 8: + raise ValueError("O número de rondas tem de estar entre 1 e 8.") + rounds = [] + can_produce = {} + for index in range(1, round_count + 1): + matches = [] + for number in range(1, 2 ** (round_count - index) + 1): + if index == 1: + a, b = Participant(team=seeds.get(number * 2 - 1)), Participant(team=seeds.get(number * 2)) + else: + previous = rounds[-1].matches + a = Participant(team=previous[number * 2 - 2].winner, + pending=previous[number * 2 - 2].winner is None and can_produce[index - 1, number * 2 - 1]) + b = Participant(team=previous[number * 2 - 1].winner, + pending=previous[number * 2 - 1].winner is None and can_produce[index - 1, number * 2]) + both = a.team is not None and b.team is not None + bye = not a.pending and not b.pending and ((a.team is None) != (b.team is None)) + empty = not a.pending and not b.pending and a.team is None and b.team is None + score_a, score_b = results.get((index, number), (None, None)) if both else (None, None) + winner = a.team or b.team if bye else None + if both and score_a is not None and score_b is not None and score_a != score_b: + winner = a.team if score_a > score_b else b.team + match = BracketMatch(match_index=number, team_a=a, team_b=b, score_a=score_a, + score_b=score_b, winner=winner, is_bye=bye) + matches.append(match) + can_produce[index, number] = not empty and (winner is not None or a.pending or b.pending or both) + count = 2 ** (round_count - index + 1) + name = {2: "Final", 4: "Meias-finais", 8: "Quartos de final", 16: "Oitavos de final"}.get(count, f"Ronda de {count}") + rounds.append(BracketRound(round_index=index, name=name, matches=matches)) + third = None + if third_place_enabled and round_count >= 2: + def loser(match): + if match.team_a.pending or match.team_b.pending: + return Participant(team=None, pending=True) + if match.team_a.team is None or match.team_b.team is None or match.winner is None: + return Participant(team=None, pending=match.team_a.team is not None and match.team_b.team is not None) + return Participant(team=match.team_b.team if match.winner.id == match.team_a.team.id else match.team_a.team) + a, b = [loser(match) for match in rounds[-2].matches] + score_a, score_b = results.get((round_count, 2), (None, None)) if a.team is not None and b.team is not None else (None, None) + winner = None + if score_a is not None and score_b is not None and score_a != score_b: + winner = a.team if score_a > score_b else b.team + third = BracketMatch(match_index=2, team_a=a, team_b=b, score_a=score_a, score_b=score_b, winner=winner) + return FinalStage(round_count=round_count, third_place_enabled=third_place_enabled, + rounds=rounds, third_place_match=third, champion=rounds[-1].matches[0].winner) + + +def get_bracket_match(stage: FinalStage, round_index: int, match_index: int) -> BracketMatch | None: + if stage.third_place_match is not None and round_index == stage.round_count and match_index == 2: + return stage.third_place_match + if 1 <= round_index <= len(stage.rounds) and 1 <= match_index <= len(stage.rounds[round_index - 1].matches): + return stage.rounds[round_index - 1].matches[match_index - 1] + return None + + +def downstream_matches(round_count: int, round_index: int, match_index: int) -> list[tuple[int, int]]: + downstream = [] + for index in range(round_index + 1, round_count + 1): + match_index = (match_index + 1) // 2 + downstream.append((index, match_index)) + return downstream diff --git a/apps/tournament/backend/app/services/calendar.py b/apps/tournament/backend/app/services/calendar.py new file mode 100644 index 0000000..e8b248f --- /dev/null +++ b/apps/tournament/backend/app/services/calendar.py @@ -0,0 +1,41 @@ +from ..schemas.calendar import CalendarMatch +from ..schemas.teams import TeamSummary + + +def group_rounds(teams: list[TeamSummary]) -> list[list[tuple[TeamSummary, TeamSummary]]]: + if len(teams) < 2: + return [] + rotation = list(teams) + if len(rotation) % 2: + rotation.append(None) + rounds = [] + for round_index in range(len(rotation) - 1): + pairs = [] + for index in range(len(rotation) // 2): + a, b = rotation[index], rotation[-index - 1] + if a is not None and b is not None: + pairs.append((b, a) if round_index % 2 and index == 0 else (a, b)) + rounds.append(pairs) + rotation.insert(1, rotation.pop()) + return rounds + + +def generate_calendar(teams: list[TeamSummary], legs: int) -> list[CalendarMatch]: + if type(legs) is not int or legs not in (1, 2): + raise ValueError("O número de voltas tem de ser 1 ou 2.") + groups = {team.group.id: team.group for team in teams} + rounds = [(group, group_rounds([team for team in teams if team.group.id == group.id])) + for group in sorted(groups.values(), key=lambda group: (group.sort_order, group.id))] + round_count = max((len(items) for _, items in rounds), default=0) + matches = [] + for index in range(round_count): + for group, items in rounds: + for a, b in items[index] if index < len(items) else []: + matches.append(CalendarMatch(game_number=len(matches) + 1, round_number=index + 1, + group=group, team_a=a, team_b=b)) + if legs == 2: + matches += [CalendarMatch(game_number=len(matches) + index + 1, + round_number=match.round_number + round_count, + group=match.group, team_a=match.team_b, team_b=match.team_a) + for index, match in enumerate(matches)] + return matches diff --git a/apps/tournament/backend/app/services/calendar_store.py b/apps/tournament/backend/app/services/calendar_store.py new file mode 100644 index 0000000..483c4c3 --- /dev/null +++ b/apps/tournament/backend/app/services/calendar_store.py @@ -0,0 +1,66 @@ +from random import SystemRandom + +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from .. import models as m +from ..errors import AppError +from ..schemas.calendar import Generation, GenerationInput, GenerationTeam +from .calendar import generate_calendar +from .state import get_groups, get_matches, get_teams + + +def summary(matches): + return {"matches": matches, "matchCount": len(matches), "roundCount": max((match.round_number for match in matches), default=0)} + + +def ordered_teams(db: Session, generation: Generation): + teams = get_teams(db) + if len(generation.team_order) != len(teams): + raise AppError(409, "As equipas foram alteradas. Sorteie novamente o calendário.") + by_id, seen, ordered = {team.id: team for team in teams}, set(), [] + for entry in generation.team_order: + team = by_id.get(entry.team_id) + if team is None or team.group.id != entry.group_id or entry.team_id in seen: + raise AppError(409, "As equipas ou os grupos foram alterados. Sorteie novamente o calendário.") + seen.add(entry.team_id) + ordered.append(team) + return ordered + + +def matches_for_generation(db: Session, generation: Generation): + matches = generate_calendar(ordered_teams(db, generation), generation.legs) + if not matches: + raise AppError(409, "São necessárias pelo menos duas equipas no mesmo grupo para gerar o calendário.") + return matches + + +def generate_preview(db: Session, legs: int): + teams = get_teams(db) + order = [] + for group in get_groups(db): + members = [team for team in teams if team.group.id == group.id] + SystemRandom().shuffle(members) + order += [GenerationTeam.model_validate({"teamId": team.id, "groupId": group.id}) for team in members] + generation = Generation.model_validate({"legs": legs, "teamOrder": order}) + return {"generation": generation, **summary(matches_for_generation(db, generation))} + + +def save_calendar(db: Session, body: GenerationInput): + matches = matches_for_generation(db, body.generation) + has_scores = db.scalar(select(m.LeagueMatch.id).where(m.LeagueMatch.score_a.is_not(None)).limit(1)) is not None + if has_scores and not body.confirm_replace: + raise AppError(409, "Já existem resultados registados. Confirme a substituição do calendário para eliminar esses resultados.") + clear_calendar(db) + for round_number in sorted({match.round_number for match in matches}): + db.add(m.LeagueRound(round_index=round_number)) + for match in matches: + db.add(m.LeagueMatch(order_index=match.game_number, round_index=match.round_number, + team_a_id=match.team_a.id, team_b_id=match.team_b.id)) + db.flush() + return summary(get_matches(db)) + + +def clear_calendar(db: Session): + db.execute(delete(m.LeagueMatch)) + db.execute(delete(m.LeagueRound)) diff --git a/apps/tournament/backend/app/services/classification.py b/apps/tournament/backend/app/services/classification.py new file mode 100644 index 0000000..84f313f --- /dev/null +++ b/apps/tournament/backend/app/services/classification.py @@ -0,0 +1,65 @@ +from itertools import groupby + +from ..schemas.calendar import LeagueMatch +from ..schemas.state import ClassificationRow +from ..schemas.teams import TeamSummary + + +def calculate_classification(teams: list[TeamSummary], matches: list[LeagueMatch], mode: str = "standard") -> list[ClassificationRow]: + statistics = {team.id: ClassificationRow(team=team) for team in teams} + completed = [match for match in matches if match.score_a is not None and match.score_b is not None] + for match in completed: + a, b = statistics.get(match.team_a.id), statistics.get(match.team_b.id) + if a is None or b is None: + continue + a.played += 1 + b.played += 1 + a.goals_for += match.score_a + a.goals_against += match.score_b + b.goals_for += match.score_b + b.goals_against += match.score_a + if mode == "total-points": + a.points += match.score_a + b.points += match.score_b + if match.score_a > match.score_b: + a.wins += 1 + b.losses += 1 + if mode == "standard": + a.points += 3 + elif match.score_b > match.score_a: + b.wins += 1 + a.losses += 1 + if mode == "standard": + b.points += 3 + else: + a.draws += 1 + b.draws += 1 + if mode == "standard": + a.points += 1 + b.points += 1 + for row in statistics.values(): + row.goal_difference = row.goals_for - row.goals_against + + def primary(row): + return (-row.points,) if mode == "total-points" else (-row.points, -row.goal_difference, -row.goals_for) + + ordered = [] + for _, group in groupby(sorted(statistics.values(), key=primary), key=primary): + tied = list(group) + head = {row.team.id: [0, 0, 0] for row in tied} + for match in completed: + if match.team_a.id not in head or match.team_b.id not in head: + continue + a, b = head[match.team_a.id], head[match.team_b.id] + a[1] += match.score_a + a[2] += match.score_b + b[1] += match.score_b + b[2] += match.score_a + a[0] += 3 if match.score_a > match.score_b else 1 if match.score_a == match.score_b else 0 + b[0] += 3 if match.score_b > match.score_a else 1 if match.score_a == match.score_b else 0 + tied.sort(key=lambda row: (-head[row.team.id][0], -(head[row.team.id][1] - head[row.team.id][2]), + -head[row.team.id][1], row.team.number)) + ordered.extend(tied) + for position, row in enumerate(ordered, 1): + row.position = position + return ordered diff --git a/apps/tournament/backend/app/services/final_stage.py b/apps/tournament/backend/app/services/final_stage.py new file mode 100644 index 0000000..449ce73 --- /dev/null +++ b/apps/tournament/backend/app/services/final_stage.py @@ -0,0 +1,130 @@ +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session + +from .. import models as m +from ..errors import AppError +from ..schemas.final_stage import ConfigInput, OverallInput, PerGroupInput, SeedsInput +from .bracket import downstream_matches, get_bracket_match +from .seeding import group_qualification, overall_qualification +from .state import get_final_stage, get_public_state, get_settings +from .teams import existing_team + + +def configure(db: Session, body: ConfigInput) -> bool: + if body.third_place_enabled and body.round_count < 2: + raise AppError(400, "O jogo do 3.º lugar requer pelo menos duas rondas.") + current = get_settings(db) + changed = current.final_round_count != body.round_count or current.third_place_enabled != body.third_place_enabled + if changed and current.final_round_count is not None and not body.confirm_clear_results: + raise AppError(409, "Alterar a configuração elimina as equipas e os resultados da fase final. Confirme esta alteração.") + if changed: + row = db.get(m.TournamentSettings, 1) + row.final_round_count, row.third_place_enabled, row.updated_at = body.round_count, int(body.third_place_enabled), func.current_timestamp() + db.execute(delete(m.FinalSeed)) + db.execute(delete(m.FinalResult)) + db.flush() + return changed + + +def clear(db: Session): + row = db.get(m.TournamentSettings, 1) + row.final_round_count, row.third_place_enabled, row.updated_at = None, 0, func.current_timestamp() + db.execute(delete(m.FinalSeed)) + db.execute(delete(m.FinalResult)) + db.flush() + + +def seed_preview(db: Session, body: PerGroupInput | OverallInput): + state = get_public_state(db) + if state.tournament.final_round_count is None: + raise AppError(409, "Configure primeiro o número de rondas da fase final.") + standings = [standing for standing in state.group_standings if standing.rows] + if not standings: + raise AppError(409, "Ainda não existem equipas classificadas para preencher a fase final.") + if not any(row.played > 0 for standing in standings for row in standing.rows): + raise AppError(409, "Ainda não existem resultados contabilizados na classificação.") + if any(match.counts_toward_standings and (match.score_a is None or match.score_b is None) for match in state.matches): + raise AppError(409, "Conclua primeiro todos os jogos que contam para a classificação.") + slots = 2 ** state.tournament.final_round_count + if isinstance(body, PerGroupInput): + for standing in standings: + if len(standing.rows) < body.qualifiers_per_group: + raise AppError(400, f"{standing.group.name} não tem {body.qualifiers_per_group} equipas para apurar.") + if len(standings) * body.qualifiers_per_group > slots: + raise AppError(400, f"A fase final só tem {slots} lugares. Reduza o número de apurados por grupo.") + return group_qualification(standings, body.qualifiers_per_group, slots) + count = sum(len(standing.rows) for standing in standings) + if body.qualifier_count > slots: + raise AppError(400, f"A fase final só tem {slots} lugares. Reduza o número total de apurados.") + if body.qualifier_count > count: + raise AppError(400, f"Só existem {count} equipas classificadas.") + return overall_qualification(standings, body.qualifier_count, slots, state.tournament.classification_mode) + + +def save_seeds(db: Session, body: SeedsInput) -> bool: + settings = get_settings(db) + if settings.final_round_count is None: + raise AppError(409, "Configure primeiro o número de rondas da fase final.") + slots = 2 ** settings.final_round_count + if len(body.seeds) != slots or {seed.slot_index for seed in body.seeds} != set(range(1, slots + 1)): + raise AppError(400, f"Indique exatamente as {slots} posições iniciais da fase final.") + assigned = [seed.team_id for seed in body.seeds if seed.team_id is not None] + if len(set(assigned)) != len(assigned): + raise AppError(400, "A mesma equipa não pode ser atribuída a mais do que uma posição.") + for identifier in assigned: + existing_team(db, identifier) + current = {row.slot_index: row.team_id for row in db.scalars(select(m.FinalSeed))} + changed = len(current) != len(body.seeds) or any(current.get(seed.slot_index) != seed.team_id for seed in body.seeds) + if changed and db.scalar(select(m.FinalResult.round_index).limit(1)) is not None and not body.confirm_clear_results: + raise AppError(409, "Alterar as equipas iniciais elimina todos os resultados da fase final. Confirme esta alteração.") + if changed: + db.execute(delete(m.FinalSeed)) + db.execute(delete(m.FinalResult)) + db.add_all([m.FinalSeed(slot_index=seed.slot_index, team_id=seed.team_id) for seed in body.seeds]) + db.flush() + return changed + + +def match_for_result(db: Session, round_index: int, match_index: int, require_teams: bool): + stage = get_final_stage(db) + if stage.round_count is None: + raise AppError(409, "A fase final ainda não está configurada.") + match = get_bracket_match(stage, round_index, match_index) + if match is None: + raise AppError(404, "O jogo da fase final não existe.") + if require_teams and (match.team_a.team is None or match.team_b.team is None or match.team_a.pending or match.team_b.pending): + raise AppError(409, "Só pode registar um resultado quando as duas equipas estiverem apuradas.") + return stage, match + + +def invalidate_results(db: Session, stage, round_index: int, match_index: int, previous_winner: int | None): + db.flush() + match = get_bracket_match(get_final_stage(db), round_index, match_index) + next_winner = match.winner.id if match.winner else None + if previous_winner == next_winner: + return + downstream = downstream_matches(stage.round_count, round_index, match_index) + if stage.third_place_enabled and round_index == stage.round_count - 1 and match_index <= 2: + downstream.append((stage.round_count, 2)) + for index, number in downstream: + db.execute(delete(m.FinalResult).where(m.FinalResult.round_index == index, m.FinalResult.match_index == number)) + + +def save_result(db: Session, round_index: int, match_index: int, score_a: int, score_b: int): + if score_a == score_b: + raise AppError(400, "Os jogos da fase final não podem terminar empatados.") + stage, match = match_for_result(db, round_index, match_index, True) + previous = match.winner.id if match.winner else None + row = db.get(m.FinalResult, (round_index, match_index)) + if row is None: + row = m.FinalResult(round_index=round_index, match_index=match_index) + db.add(row) + row.score_a, row.score_b = score_a, score_b + invalidate_results(db, stage, round_index, match_index, previous) + + +def clear_result(db: Session, round_index: int, match_index: int): + stage, match = match_for_result(db, round_index, match_index, False) + previous = match.winner.id if match.winner else None + db.execute(delete(m.FinalResult).where(m.FinalResult.round_index == round_index, m.FinalResult.match_index == match_index)) + invalidate_results(db, stage, round_index, match_index, previous) diff --git a/apps/tournament/backend/app/services/seeding.py b/apps/tournament/backend/app/services/seeding.py new file mode 100644 index 0000000..5c92d5b --- /dev/null +++ b/apps/tournament/backend/app/services/seeding.py @@ -0,0 +1,49 @@ +from ..schemas.final_stage import QualifiedTeam, Seed, SeedPreview +from ..schemas.state import GroupStanding + + +def seed_order(slot_count: int) -> list[int]: + if type(slot_count) is not int or slot_count < 1 or slot_count & (slot_count - 1): + raise ValueError("O número de lugares da fase final tem de ser uma potência de dois.") + if slot_count == 1: + return [1] + order, size = [1, 2], 4 + while size <= slot_count: + order = [value for seed in order for value in (seed, size + 1 - seed)] + size *= 2 + return order + + +def preview(qualifiers: list[QualifiedTeam], slot_count: int, mode: str, per_group: int | None) -> SeedPreview: + if len(qualifiers) > slot_count: + raise ValueError("Existem mais equipas apuradas do que lugares na fase final.") + by_seed = {item.seed_number: item.team.id for item in qualifiers} + return SeedPreview(mode=mode, qualifiers_per_group=per_group, qualifier_count=len(qualifiers), + qualifiers=qualifiers, seeds=[Seed(slot_index=index, team_id=by_seed.get(seed)) + for index, seed in enumerate(seed_order(slot_count), 1)]) + + +def group_qualification(standings: list[GroupStanding], per_group: int, slot_count: int) -> SeedPreview: + if type(per_group) is not int or per_group < 1: + raise ValueError("O número de apurados por grupo tem de ser positivo.") + standings = sorted((item for item in standings if item.rows), key=lambda item: (item.group.sort_order, item.group.id)) + qualifiers = [] + for position in range(1, per_group + 1): + for standing in standings: + if len(standing.rows) >= position: + qualifiers.append(QualifiedTeam(seed_number=len(qualifiers) + 1, group_position=position, + team=standing.rows[position - 1].team)) + return preview(qualifiers, slot_count, "per-group", per_group) + + +def overall_qualification(standings: list[GroupStanding], count: int, slot_count: int, mode: str) -> SeedPreview: + if type(count) is not int or count < 1: + raise ValueError("O número total de apurados tem de ser positivo.") + def key(row): + return (-row.points, row.team.number) if mode == "total-points" else (-row.points, -row.goal_difference, -row.goals_for, row.team.number) + rows = sorted((row for standing in standings for row in standing.rows), key=key) + if count > len(rows): + raise ValueError("Não existem equipas suficientes para esse número de apurados.") + qualifiers = [QualifiedTeam(seed_number=index, group_position=row.position, team=row.team) + for index, row in enumerate(rows[:count], 1)] + return preview(qualifiers, slot_count, "overall", None) diff --git a/apps/tournament/backend/app/services/state.py b/apps/tournament/backend/app/services/state.py new file mode 100644 index 0000000..8a23917 --- /dev/null +++ b/apps/tournament/backend/app/services/state.py @@ -0,0 +1,82 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from .. import models as m +from ..schemas.calendar import LeagueMatch +from ..schemas.settings import DisplaySettings, TournamentSettings +from ..schemas.state import GroupStanding, PublicState +from ..schemas.teams import Group, Player, Team, TeamSummary +from .bracket import calculate_bracket +from .classification import calculate_classification + + +def get_settings(db: Session) -> TournamentSettings: + row = db.get(m.TournamentSettings, 1) + if row is None: + raise RuntimeError("As definições do torneio não foram inicializadas.") + return TournamentSettings(name=row.name, classification_mode=row.classification_mode, + final_round_count=row.final_round_count, third_place_enabled=bool(row.third_place_enabled)) + + +def get_display(db: Session) -> DisplaySettings: + row = db.get(m.DisplaySettings, 1) + if row is None: + raise RuntimeError("As definições do ecrã não foram inicializadas.") + return DisplaySettings(active_panel=row.active_panel if row.active_panel in ("next-match", "latest-results", "classification") else "latest-results", + zoom_percent=row.zoom_percent if type(row.zoom_percent) is int and 50 <= row.zoom_percent <= 400 else 100) + + +def group_from_row(row: m.Group) -> Group: + return Group(id=row.id, name=row.name, sort_order=row.sort_order) + + +def get_groups(db: Session) -> list[Group]: + return [group_from_row(row) for row in db.scalars(select(m.Group).order_by(m.Group.sort_order, m.Group.id))] + + +def get_teams(db: Session) -> list[TeamSummary]: + return [TeamSummary(id=team.id, number=team.number, name=team.name, group=group_from_row(group)) + for team, group in db.execute(select(m.Team, m.Group).join(m.Group, m.Team.group_id == m.Group.id).order_by(m.Team.number))] + + +def player_from_row(row: m.Player) -> Player: + return Player(id=row.id, team_id=row.team_id, name=row.name, sort_order=row.sort_order) + + +def get_admin_teams(db: Session) -> list[Team]: + teams = [Team(**team.model_dump()) for team in get_teams(db)] + by_id = {team.id: team for team in teams} + for player in db.scalars(select(m.Player).order_by(m.Player.team_id, m.Player.sort_order, m.Player.id)): + if player.team_id in by_id: + by_id[player.team_id].players.append(player_from_row(player)) + return teams + + +def get_matches(db: Session) -> list[LeagueMatch]: + teams = {team.id: team for team in get_teams(db)} + result = [] + for row, round_row in db.execute(select(m.LeagueMatch, m.LeagueRound).join(m.LeagueRound, m.LeagueRound.round_index == m.LeagueMatch.round_index).order_by(m.LeagueMatch.order_index)): + a, b = teams[row.team_a_id], teams[row.team_b_id] + result.append(LeagueMatch(id=row.id, game_number=row.order_index, round_number=row.round_index, + counts_toward_standings=bool(round_row.counts_toward_standings), + group=a.group if a.group.id == b.group.id else None, + team_a=a, team_b=b, score_a=row.score_a, score_b=row.score_b)) + return result + + +def get_final_stage(db: Session): + settings = get_settings(db) + teams = {team.id: team for team in get_teams(db)} + seeds = {row.slot_index: teams.get(row.team_id) for row in db.scalars(select(m.FinalSeed))} + results = {(row.round_index, row.match_index): (row.score_a, row.score_b) for row in db.scalars(select(m.FinalResult))} + return calculate_bracket(settings.final_round_count, seeds, results, settings.third_place_enabled) + + +def get_public_state(db: Session) -> PublicState: + settings, groups, teams, matches = get_settings(db), get_groups(db), get_teams(db), get_matches(db) + standings = [GroupStanding(group=group, rows=calculate_classification( + [team for team in teams if team.group.id == group.id], + [match for match in matches if match.counts_toward_standings and match.team_a.group.id == group.id and match.team_b.group.id == group.id], + settings.classification_mode)) for group in groups] + return PublicState(tournament=settings, display=get_display(db), groups=groups, matches=matches, + group_standings=standings, final_stage=get_final_stage(db)) diff --git a/apps/tournament/backend/app/services/teams.py b/apps/tournament/backend/app/services/teams.py new file mode 100644 index 0000000..e896cf6 --- /dev/null +++ b/apps/tournament/backend/app/services/teams.py @@ -0,0 +1,116 @@ +from random import SystemRandom + +from sqlalchemy import delete, func, or_, select +from sqlalchemy.orm import Session + +from .. import models as m +from ..errors import AppError +from ..schemas.base import MAX_SAFE_INTEGER +from ..schemas.teams import TeamCreate, TeamInput +from .state import get_admin_teams, get_groups, group_from_row + + +def existing_group(db: Session, identifier: int): + row = db.get(m.Group, identifier) + if row is None: + raise AppError(404, "O grupo não existe.") + return row + + +def existing_team(db: Session, identifier: int): + row = db.get(m.Team, identifier) + if row is None: + raise AppError(404, "A equipa não existe.") + return row + + +def available_group_name(db: Session, name: str, except_id: int | None = None): + query = select(m.Group).where(m.Group.name == name) + if except_id is not None: + query = query.where(m.Group.id != except_id) + if db.scalar(query) is not None: + raise AppError(409, "Já existe um grupo com esse nome.") + + +def create_group(db: Session, name: str): + available_group_name(db, name) + order = db.scalar(select(func.max(m.Group.sort_order))) + row = m.Group(name=name, sort_order=0 if order is None else order + 1) + db.add(row) + db.flush() + return group_from_row(row) + + +def update_group(db: Session, identifier: int, name: str): + row = existing_group(db, identifier) + available_group_name(db, name, identifier) + row.name, row.updated_at = name, func.current_timestamp() + db.flush() + return group_from_row(row) + + +def delete_group(db: Session, identifier: int): + row = existing_group(db, identifier) + if db.scalar(select(m.Team.id).where(m.Team.group_id == identifier).limit(1)) is not None: + raise AppError(409, "Não é possível eliminar um grupo com equipas.") + if db.scalar(select(func.count()).select_from(m.Group)) <= 1: + raise AppError(409, "Não é possível eliminar o último grupo.") + db.delete(row) + + +def used_in_calendar(db: Session, identifier: int) -> bool: + return db.scalar(select(m.LeagueMatch.id).where(or_(m.LeagueMatch.team_a_id == identifier, + m.LeagueMatch.team_b_id == identifier)).limit(1)) is not None + + +def create_team(db: Session, body: TeamCreate): + existing_group(db, body.group_id) + number = body.number + if number is None: + maximum = db.scalar(select(func.max(m.Team.number))) or 0 + if maximum >= MAX_SAFE_INTEGER: + raise AppError(409, "Não é possível atribuir outro número de equipa.") + number = maximum + 1 + row = m.Team(number=number, name=body.name, group_id=body.group_id) + db.add(row) + db.flush() + return next(team for team in get_admin_teams(db) if team.id == row.id) + + +def update_team(db: Session, identifier: int, body: TeamInput): + row = existing_team(db, identifier) + existing_group(db, body.group_id) + if row.group_id != body.group_id and used_in_calendar(db, identifier): + raise AppError(409, "Não é possível alterar o grupo de uma equipa utilizada no calendário. Limpe ou substitua o calendário primeiro.") + row.number, row.name, row.group_id, row.updated_at = body.number, body.name, body.group_id, func.current_timestamp() + db.flush() + return next(team for team in get_admin_teams(db) if team.id == identifier) + + +def delete_team(db: Session, identifier: int): + row = existing_team(db, identifier) + if used_in_calendar(db, identifier): + raise AppError(409, "Não é possível eliminar esta equipa porque está a ser utilizada no calendário.") + if db.scalar(select(m.FinalSeed.slot_index).where(m.FinalSeed.team_id == identifier).limit(1)) is not None: + raise AppError(409, "Não é possível eliminar esta equipa porque está a ser utilizada na fase final.") + db.delete(row) + + +def randomize_teams(db: Session): + teams = list(db.scalars(select(m.Team).order_by(m.Team.number))) + groups = get_groups(db) + has_calendar = db.scalar(select(m.LeagueMatch.id).limit(1)) is not None + if has_calendar: + db.execute(delete(m.LeagueMatch)) + db.execute(delete(m.LeagueRound)) + maximum = max((row.number for row in teams), default=0) + numbers, shuffled_groups = [row.number for row in teams], list(groups) + SystemRandom().shuffle(numbers) + SystemRandom().shuffle(shuffled_groups) + for index, row in enumerate(teams, 1): + row.number = maximum + index + db.flush() + for index, row in enumerate(teams): + row.number, row.group_id, row.updated_at = numbers[index], shuffled_groups[index % len(groups)].id, func.current_timestamp() + db.flush() + return {"teams": get_admin_teams(db), "calendarCleared": has_calendar} diff --git a/apps/tournament/backend/migrations/env.py b/apps/tournament/backend/migrations/env.py new file mode 100644 index 0000000..f872dc9 --- /dev/null +++ b/apps/tournament/backend/migrations/env.py @@ -0,0 +1,28 @@ +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.models import Base + +config = context.config + + +def migrate(connection): + context.configure(connection=connection, target_metadata=Base.metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + context.configure(url=config.get_main_option("sqlalchemy.url"), target_metadata=Base.metadata, + literal_binds=True, dialect_opts={"paramstyle": "named"}) + with context.begin_transaction(): + context.run_migrations() +elif config.attributes.get("connection") is not None: + migrate(config.attributes["connection"]) +else: + from app.config import Settings + from app.database import create_database_engine + engine = create_database_engine(Settings().database_path) + with engine.connect() as connection: + migrate(connection) + engine.dispose() diff --git a/apps/tournament/backend/migrations/versions/0001_baseline.py b/apps/tournament/backend/migrations/versions/0001_baseline.py new file mode 100644 index 0000000..50f4ef4 --- /dev/null +++ b/apps/tournament/backend/migrations/versions/0001_baseline.py @@ -0,0 +1,88 @@ +"""Exact baseline of the Node SQLite schema; adopted databases are stamped only.""" + +from alembic import op + +revision = "0001_baseline" +down_revision = None +branch_labels = None +depends_on = None + +# Frozen SQL belongs to this migration, never application startup or runtime models. +SCHEMA = """ +CREATE TABLE tournament_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT NOT NULL CHECK (length(trim(name)) > 0), + final_round_count INTEGER CHECK (final_round_count IS NULL OR (final_round_count BETWEEN 1 AND 8)), + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + third_place_enabled INTEGER NOT NULL DEFAULT 0 CHECK (third_place_enabled IN (0, 1)), + classification_mode TEXT NOT NULL DEFAULT 'standard' CHECK (classification_mode IN ('standard', 'total-points')) +); +CREATE TABLE display_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + active_panel TEXT NOT NULL CHECK (length(trim(active_panel)) > 0), + zoom_percent INTEGER NOT NULL DEFAULT 100 CHECK (zoom_percent BETWEEN 50 AND 400), + created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE league_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE UNIQUE CHECK (length(trim(name)) > 0), + sort_order INTEGER NOT NULL UNIQUE CHECK (sort_order >= 0), + created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + number INTEGER NOT NULL UNIQUE CHECK (number > 0), + name TEXT NOT NULL CHECK (length(trim(name)) > 0), + group_id INTEGER NOT NULL REFERENCES league_groups(id), + created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + name TEXT NOT NULL CHECK (length(trim(name)) > 0), + sort_order INTEGER NOT NULL CHECK (sort_order >= 0), + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE +); +CREATE TABLE league_matches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_index INTEGER NOT NULL UNIQUE CHECK (order_index >= 1), + round_index INTEGER NOT NULL CHECK (round_index >= 1), + team_a_id INTEGER NOT NULL, team_b_id INTEGER NOT NULL, score_a INTEGER, score_b INTEGER, + CHECK (team_a_id <> team_b_id), + CHECK ((score_a IS NULL AND score_b IS NULL) OR (score_a IS NOT NULL AND score_b IS NOT NULL AND score_a >= 0 AND score_b >= 0)), + FOREIGN KEY (team_a_id) REFERENCES teams(id), FOREIGN KEY (team_b_id) REFERENCES teams(id) +); +CREATE TABLE final_seeds ( + slot_index INTEGER PRIMARY KEY CHECK (slot_index >= 1), + team_id INTEGER, FOREIGN KEY (team_id) REFERENCES teams(id) +); +CREATE TABLE final_match_results ( + round_index INTEGER NOT NULL CHECK (round_index >= 1), + match_index INTEGER NOT NULL CHECK (match_index >= 1), score_a INTEGER, score_b INTEGER, + PRIMARY KEY (round_index, match_index), + CHECK ((score_a IS NULL AND score_b IS NULL) OR (score_a IS NOT NULL AND score_b IS NOT NULL AND score_a >= 0 AND score_b >= 0)) +); +CREATE UNIQUE INDEX idx_final_seeds_unique_team ON final_seeds(team_id) WHERE team_id IS NOT NULL; +CREATE INDEX idx_teams_group ON teams(group_id); +CREATE INDEX idx_players_team_order ON players(team_id, sort_order); +CREATE INDEX idx_league_matches_order ON league_matches(order_index); +CREATE TABLE league_rounds ( + round_index INTEGER PRIMARY KEY CHECK (round_index >= 1), + counts_toward_standings INTEGER NOT NULL DEFAULT 0 CHECK (counts_toward_standings IN (0, 1)) +); +INSERT INTO tournament_settings (id, name, created_at, updated_at) VALUES (1, 'Torneio', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); +INSERT INTO display_settings (id, active_panel, created_at, updated_at) VALUES (1, 'latest-results', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); +INSERT INTO league_groups (id, name, sort_order, created_at, updated_at) VALUES (1, 'Grupo A', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); +""" + + +def upgrade() -> None: + for statement in SCHEMA.split(";"): + if statement.strip(): + op.execute(statement) + + +def downgrade() -> None: + for table in ["final_match_results", "final_seeds", "players", "league_matches", "league_rounds", + "teams", "league_groups", "display_settings", "tournament_settings"]: + op.drop_table(table) diff --git a/apps/tournament/backend/pyproject.toml b/apps/tournament/backend/pyproject.toml new file mode 100644 index 0000000..4ca91d0 --- /dev/null +++ b/apps/tournament/backend/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "crr-tournament" +version = "1.0.0" +description = "CRR Tournament FastAPI backend" +requires-python = ">=3.13,<3.14" +dependencies = [ + "fastapi>=0.115,<1", + "uvicorn[standard]>=0.30,<1", + "SQLAlchemy>=2.0,<3", + "alembic>=1.13,<2", + "pydantic-settings>=2.5,<3", +] + +[dependency-groups] +dev = ["pytest>=8,<9", "httpx>=0.27,<1"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] +addopts = "-ra" diff --git a/apps/tournament/backend/tests/conftest.py b/apps/tournament/backend/tests/conftest.py new file mode 100644 index 0000000..e19a4eb --- /dev/null +++ b/apps/tournament/backend/tests/conftest.py @@ -0,0 +1,25 @@ +import pytest +from fastapi.testclient import TestClient + +from app.config import Settings +from app.main import create_app + + +@pytest.fixture +def settings(tmp_path): + return Settings(admin_password="migration-fixture-password", session_secret="migration-fixture-secret-not-for-production", + database_path=str(tmp_path / "tournament.sqlite"), app_base_path="/jogo/") + + +@pytest.fixture +def client(settings): + with TestClient(create_app(settings)) as client: + yield client + + +@pytest.fixture +def admin(client): + response = client.post("/api/auth/login", json={"password": "migration-fixture-password"}) + assert response.status_code == 200 + client.headers["cookie"] = f"tournament_admin_session={response.cookies['tournament_admin_session']}" + return client diff --git a/apps/tournament/backend/tests/test_api_compatibility.py b/apps/tournament/backend/tests/test_api_compatibility.py new file mode 100644 index 0000000..ff217a4 --- /dev/null +++ b/apps/tournament/backend/tests/test_api_compatibility.py @@ -0,0 +1,109 @@ +import json +from pathlib import Path +import shutil + +import pytest +from fastapi.testclient import TestClient + +from app.main import create_app + +FIXTURES = Path(__file__).resolve().parents[4] / "migration/fixtures" + + +def test_replays_saved_node_workflow_statuses_and_json(client): + token = None + for observation in json.loads((FIXTURES / "tournament-api.json").read_text()): + request, expected = observation["request"], observation["response"] + headers = {"cookie": token} if request["authorized"] and token else {} + response = client.request(request["method"], request["url"], json=request.get("payload"), headers=headers) + assert response.status_code == expected["status"], (request, response.text) + assert response.json() == expected["body"], request + if request["url"] == "/api/auth/login" and response.status_code == 200: + token = f"tournament_admin_session={response.cookies['tournament_admin_session']}" + + +def test_python_startup_adopts_node_database_and_preserves_public_state(settings): + shutil.copyfile(FIXTURES / "tournament.sqlite", settings.database_path) + with TestClient(create_app(settings)) as client: + response = client.get("/api/public/state") + assert response.status_code == 200 + assert response.json() == json.loads((FIXTURES / "tournament-state.json").read_text()) + # The Node-era rows remain unchanged, including timestamps and sequence-backed IDs. + import sqlite3 + with sqlite3.connect(settings.database_path) as db: + db.row_factory = sqlite3.Row + for table, rows in json.loads((FIXTURES / "tournament-rows.json").read_text()).items(): + assert [dict(row) for row in db.execute(f'SELECT * FROM "{table}" ORDER BY rowid')] == rows + + +@pytest.mark.parametrize("payload", [None, {}, {"password": ""}, {"password": 123}]) +def test_login_validation_error_shape(client, payload): + response = client.post("/api/auth/login", json=payload) + assert response.status_code == 400 + assert response.json() == {"error": "Os dados enviados não são válidos."} + + +def test_login_limiter_cookie_and_logout(client): + for _ in range(5): + assert client.post("/api/auth/login", json={"password": "wrong"}).status_code == 401 + assert client.post("/api/auth/login", json={"password": "migration-fixture-password"}).status_code == 429 + response = client.post("/api/auth/logout") + assert response.json() == {"authenticated": False} + assert "Path=/jogo/" in response.headers["set-cookie"] + assert "HttpOnly" in response.headers["set-cookie"] + + +def test_head_and_malformed_json_follow_legacy_contract(client): + response = client.head("/api/public/state") + assert response.status_code == 200 and response.content == b"" + response = client.post("/api/auth/login", content="{", headers={"content-type": "application/json"}) + assert response.status_code == 500 + assert response.json() == {"error": "Ocorreu um erro inesperado. Tente novamente."} + + +@pytest.mark.parametrize("payload", [ + {"name": " ", "groupId": 1}, {"name": "T", "groupId": 1, "number": "1"}, + {"name": "T", "groupId": 1, "number": True}, {"name": "T", "groupId": 1, "number": 1.5}, +]) +def test_rejects_invalid_team_payloads(admin, payload): + response = admin.post("/api/admin/teams", json=payload) + assert response.status_code == 400 + + +def test_all_saved_api_routes_exist_with_matching_methods(settings): + app = create_app(settings) + routes = {(method.upper(), path) for path, operations in app.openapi()["paths"].items() for method in operations} + for expected in json.loads((FIXTURES / "tournament-routes.json").read_text()): + import re + path = re.sub(r":(\w+)", r"{\1}", expected["path"]) + assert (expected["method"], path) in routes + app.state.engine.dispose() + + +def test_request_aliases_and_unicode_name_limits_follow_zod(admin): + assert admin.put("/api/admin/display", json={"active_panel": "classification", "zoom_percent": 100}).status_code == 400 + assert admin.post("/api/admin/teams", json={"name": "T", "group_id": 1}).status_code == 400 + assert admin.post("/api/admin/teams", json={"name": "T", "groupId": 1, "number": None}).status_code == 400 + assert admin.post("/api/admin/teams", json={"name": "\U0001f600" * 101, "groupId": 1}).status_code == 400 + response = admin.post("/api/admin/teams", json={"name": "\ufeff Equipa \ufeff", "groupId": "0x1"}) + assert response.status_code == 201 + assert response.json()["name"] == "Equipa" + + +def test_concurrent_automatic_team_numbers_are_unique_and_contiguous(admin): + from concurrent.futures import ThreadPoolExecutor + def create(index): + response = admin.post("/api/admin/teams", json={"name": f"Equipa {index}", "groupId": 1}) + assert response.status_code == 201 + return response.json()["number"] + with ThreadPoolExecutor(max_workers=4) as pool: + assert sorted(pool.map(create, range(4))) == [1, 2, 3, 4] + + +def test_legacy_display_fallback_for_unknown_panel_and_fractional_sqlite_zoom(client): + # SQLite INTEGER affinity permits fractional values within the legacy BETWEEN check. + with client.app.state.engine.begin() as connection: + connection.exec_driver_sql("UPDATE display_settings SET active_panel='old-panel', zoom_percent=100.5") + response = client.get("/api/public/state") + assert response.status_code == 200 + assert response.json()["display"] == {"activePanel": "latest-results", "zoomPercent": 100} diff --git a/apps/tournament/backend/tests/test_database.py b/apps/tournament/backend/tests/test_database.py new file mode 100644 index 0000000..3108fe3 --- /dev/null +++ b/apps/tournament/backend/tests/test_database.py @@ -0,0 +1,89 @@ +import json +from pathlib import Path +import shutil + +import pytest +from sqlalchemy import inspect +from sqlalchemy.exc import IntegrityError + +from app.database import create_database_engine, normalized, run_migrations, validate_legacy_schema + +FIXTURES = Path(__file__).resolve().parents[4] / "migration/fixtures" + + +def test_fresh_database_matches_node_schema_and_defaults(tmp_path): + engine = create_database_engine(str(tmp_path / "new.sqlite")) + try: + run_migrations(engine) + with engine.connect() as connection: + validate_legacy_schema(connection) + expected = json.loads((FIXTURES / "tournament-schema.json").read_text()) + for item in expected: + actual = connection.exec_driver_sql("SELECT sql FROM sqlite_master WHERE name=?", (item["name"],)).scalar_one() + assert normalized(actual) == normalized(item["sql"]), item["name"] + assert connection.exec_driver_sql("SELECT name FROM tournament_settings").scalar_one() == "Torneio" + assert connection.exec_driver_sql("PRAGMA foreign_keys").scalar_one() == 1 + assert connection.exec_driver_sql("PRAGMA journal_mode").scalar_one() == "wal" + assert connection.exec_driver_sql("PRAGMA busy_timeout").scalar_one() == 5000 + finally: + engine.dispose() + + +def test_adopts_populated_node_database_without_modifying_rows_or_schema(tmp_path): + path = tmp_path / "legacy.sqlite" + shutil.copyfile(FIXTURES / "tournament.sqlite", path) + engine = create_database_engine(str(path)) + try: + run_migrations(engine) + run_migrations(engine) + with engine.connect() as connection: + for name, rows in json.loads((FIXTURES / "tournament-rows.json").read_text()).items(): + assert [dict(row) for row in connection.exec_driver_sql(f'SELECT * FROM "{name}" ORDER BY rowid').mappings()] == rows + assert connection.exec_driver_sql("SELECT version_num FROM alembic_version").scalar_one() == "0001_baseline" + finally: + engine.dispose() + + +@pytest.mark.parametrize("damage", ["DROP TABLE players", "ALTER TABLE teams ADD COLUMN unexpected TEXT", "DROP INDEX idx_teams_group"]) +def test_refuses_incompatible_legacy_database_without_stamping(tmp_path, damage): + path = tmp_path / "bad.sqlite" + shutil.copyfile(FIXTURES / "tournament.sqlite", path) + engine = create_database_engine(str(path)) + try: + with engine.begin() as connection: + connection.exec_driver_sql(damage) + with pytest.raises(RuntimeError): + run_migrations(engine) + assert "alembic_version" not in inspect(engine).get_table_names() + finally: + engine.dispose() + + +def test_database_constraints_and_player_cascade(tmp_path): + engine = create_database_engine(str(tmp_path / "constraints.sqlite")) + try: + run_migrations(engine) + with engine.begin() as connection: + connection.exec_driver_sql("INSERT INTO teams(number,name,group_id,created_at,updated_at) VALUES(1,'Equipa',1,'t','t')") + connection.exec_driver_sql("INSERT INTO players(team_id,name,sort_order) VALUES(1,'Jogador',0)") + connection.exec_driver_sql("DELETE FROM teams WHERE id=1") + assert connection.exec_driver_sql("SELECT count(*) FROM players").scalar_one() == 0 + with pytest.raises(IntegrityError), engine.begin() as connection: + connection.exec_driver_sql("INSERT INTO league_groups(name,sort_order,created_at,updated_at) VALUES('grupo a',1,'t','t')") + finally: + engine.dispose() + + +def test_failed_upgrade_rolls_back_schema_creation(tmp_path, monkeypatch): + from app import database + engine = create_database_engine(str(tmp_path / "failed.sqlite")) + def fail(config, revision): + config.attributes["connection"].exec_driver_sql("CREATE TABLE incomplete(id INTEGER)") + raise RuntimeError("migration failed") + monkeypatch.setattr(database.command, "upgrade", fail) + try: + with pytest.raises(RuntimeError, match="migration failed"): + run_migrations(engine) + assert not inspect(engine).get_table_names() + finally: + engine.dispose() diff --git a/apps/tournament/backend/tests/test_events.py b/apps/tournament/backend/tests/test_events.py new file mode 100644 index 0000000..b94c951 --- /dev/null +++ b/apps/tournament/backend/tests/test_events.py @@ -0,0 +1,16 @@ +import asyncio + +from app.events import StateChangeEvents + + +def test_notification_only_sse_retry_heartbeat_broadcast_and_cleanup(): + async def scenario(): + broker = StateChangeEvents(heartbeat_seconds=0.02) + stream = broker.stream() + assert await anext(stream) == "retry: 3000\n\n" + broker.broadcast() + assert await anext(stream) == 'event: state-changed\ndata: {"type":"state-changed"}\n\n' + assert await anext(stream) == ": heartbeat\n\n" + await stream.aclose() + assert not broker.clients + asyncio.run(scenario()) diff --git a/apps/tournament/backend/tests/test_security.py b/apps/tournament/backend/tests/test_security.py new file mode 100644 index 0000000..eb079f1 --- /dev/null +++ b/apps/tournament/backend/tests/test_security.py @@ -0,0 +1,55 @@ +from fastapi.testclient import TestClient + +from app.main import create_app +from app.security.rate_limit import LoginRateLimiter +from app.security.session import create_token, valid_token + + +def test_session_matches_node_wire_format_and_rejects_expired_or_tampered_tokens(settings): + # Produced by the frozen Node createSessionToken implementation at now=2000. + token = "eyJleHBpcmVzQXQiOjYwNDgwMjAwMH0.MbES4fCzzaBuj5tLh_9osvDnYNwSKDYBlSYPpF-iJQo" + assert create_token(settings, now=2000) == token + assert valid_token(token, settings, now=2000) + assert not valid_token(token, settings, now=604802000) + assert not valid_token(token + "x", settings, now=2000) + assert not valid_token(token + ".extra", settings) + assert not valid_token("malformed", settings) + + +def test_limiter_expiry_ip_isolation_and_successful_login_reset(client): + limiter = LoginRateLimiter() + for _ in range(5): + limiter.failure("a", now=1000) + assert limiter.limited("a", now=1000) + assert not limiter.limited("b", now=1000) + assert not limiter.limited("a", now=901000) + assert not limiter.attempts + for _ in range(4): + assert client.post("/api/auth/login", json={"password": "wrong"}).status_code == 401 + assert client.post("/api/auth/login", json={"password": "migration-fixture-password"}).status_code == 200 + for _ in range(5): + assert client.post("/api/auth/login", json={"password": "wrong"}).status_code == 401 + assert client.post("/api/auth/login", json={"password": "wrong"}).status_code == 429 + + +def test_cookie_https_and_proxy_trust(settings): + for trust, secure in [(False, False), (True, True)]: + with TestClient(create_app(settings.model_copy(update={"trust_proxy": trust}))) as client: + response = client.post("/api/auth/login", json={"password": "migration-fixture-password"}, + headers={"x-forwarded-proto": "https"}) + cookie = response.headers["set-cookie"] + assert ("; Secure" in cookie) is secure + assert "HttpOnly" in cookie and "SameSite=strict" in cookie + assert "Max-Age=604800" in cookie and "Path=/jogo/" in cookie + with TestClient(create_app(settings), base_url="https://testserver") as client: + response = client.post("/api/auth/login", json={"password": "migration-fixture-password"}) + assert "; Secure" in response.headers["set-cookie"] + + +def test_parallel_failed_logins_do_not_bypass_five_attempt_limit(client): + from collections import Counter + from concurrent.futures import ThreadPoolExecutor + def attempt(_index): + return client.post("/api/auth/login", json={"password": "wrong"}).status_code + with ThreadPoolExecutor(max_workers=6) as pool: + assert Counter(pool.map(attempt, range(6))) == {401: 5, 429: 1} diff --git a/apps/tournament/package-lock.json b/apps/tournament/package-lock.json index c1b0182..e311e29 100644 --- a/apps/tournament/package-lock.json +++ b/apps/tournament/package-lock.json @@ -8,19 +8,12 @@ "name": "tournament-manager", "version": "1.0.0", "dependencies": { - "@fastify/cookie": "^11.0.2", - "@fastify/static": "^10.1.3", - "better-sqlite3": "^13.0.3", - "fastify": "^5.2.1", "vue": "^3.5.13", - "vue-router": "^4.5.0", - "zod": "^3.24.1" + "vue-router": "^4.5.0" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.5", "@vitejs/plugin-vue": "^5.2.1", - "tsx": "^4.19.2", "typescript": "^5.7.2", "vite": "^6.0.7", "vitest": "^2.1.8", @@ -76,658 +69,12 @@ "node": ">=6.9.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@fastify/accept-negotiator": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", - "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/ajv-compiler": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", - "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^4.0.0" - } - }, - "node_modules/@fastify/cookie": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", - "integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "cookie": "^2.0.0", - "fastify-plugin": "^6.0.0" - } - }, - "node_modules/@fastify/error": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", - "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", - "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "fast-json-stringify": "^7.0.0" - } - }, - "node_modules/@fastify/forwarded": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", - "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/merge-json-schemas": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", - "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@fastify/proxy-addr": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", - "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/forwarded": "^3.0.0", - "ipaddr.js": "^2.1.0" - } - }, - "node_modules/@fastify/send": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz", - "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@lukeed/ms": "^2.0.2", - "escape-html": "~1.0.3", - "fast-decode-uri-component": "^1.0.1", - "http-errors": "^2.0.0", - "mime": "^3" - } - }, - "node_modules/@fastify/static": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.3.tgz", - "integrity": "sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/accept-negotiator": "^2.0.0", - "@fastify/error": "^4.0.0", - "@fastify/send": "^4.0.0", - "content-disposition": "^2.0.1", - "fastify-plugin": "^6.0.0", - "fastq": "^1.17.1", - "glob": "^13.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, - "node_modules/@lukeed/ms": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", - "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -748,12 +95,6 @@ "node": "^22.20 || ^24.12 || >=25" } }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", @@ -1143,16 +484,6 @@ "win32" ] }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1472,61 +803,6 @@ "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", "license": "MIT" }, - "node_modules/abstract-logging": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", - "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", - "license": "MIT" - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv/node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/alien-signals": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", @@ -1544,68 +820,6 @@ "node": ">=12" } }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/avvio": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", - "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/error": "^4.0.0", - "fastq": "^1.17.1" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/better-sqlite3": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", - "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", - "license": "MIT", - "dependencies": { - "node-addon-api": "^8.0.0" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1643,32 +857,6 @@ "node": ">= 16" } }, - "node_modules/content-disposition": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", - "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cookie": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", - "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", - "license": "MIT", - "engines": { - "node": ">=22" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1710,24 +898,6 @@ "node": ">=6" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1747,54 +917,6 @@ "dev": true, "license": "MIT" }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -1811,125 +933,6 @@ "node": ">=12.0.0" } }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stringify": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", - "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/merge-json-schemas": "^0.2.0", - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^4.0.0", - "json-schema-ref-resolver": "^3.0.0", - "rfdc": "^1.2.0" - } - }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "license": "MIT", - "dependencies": { - "fast-decode-uri-component": "^1.0.1" - } - }, - "node_modules/fast-uri": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", - "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastify": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz", - "integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/ajv-compiler": "^4.0.5", - "@fastify/error": "^4.0.0", - "@fastify/fast-json-stringify-compiler": "^5.0.0", - "@fastify/proxy-addr": "^5.0.0", - "abstract-logging": "^2.0.1", - "avvio": "^9.0.0", - "fast-json-stringify": "^7.0.0", - "find-my-way": "^9.6.0", - "light-my-request": "^6.0.0", - "pino": "^9.14.0 || ^10.1.0", - "process-warning": "^5.1.0", - "rfdc": "^1.3.1", - "secure-json-parse": "^4.0.0", - "semver": "^7.6.0", - "toad-cache": "^3.7.0" - } - }, - "node_modules/fastify-plugin": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", - "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", - "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1948,20 +951,6 @@ } } }, - "node_modules/find-my-way": { - "version": "9.9.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", - "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^5.0.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1977,23 +966,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -2004,116 +976,6 @@ "he": "bin/he" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", - "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/json-schema-ref-resolver": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", - "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/light-my-request": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", - "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "dependencies": { - "cookie": "^1.0.1", - "process-warning": "^4.0.0", - "set-cookie-parser": "^2.6.0" - } - }, - "node_modules/light-my-request/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/light-my-request/node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2121,15 +983,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2139,42 +992,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2207,24 +1024,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-addon-api": { - "version": "8.9.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", - "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -2232,22 +1031,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -2284,43 +1067,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pino": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", - "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^4.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -2349,71 +1095,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/process-warning": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", - "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT" - }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -2460,77 +1141,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/safe-regex2": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", - "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ret": "~0.5.0" - }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2538,15 +1148,6 @@ "dev": true, "license": "ISC" }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2556,15 +1157,6 @@ "node": ">=0.10.0" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2572,15 +1164,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -2588,24 +1171,6 @@ "dev": true, "license": "MIT" }, - "node_modules/thread-stream": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", - "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", - "license": "MIT", - "dependencies": { - "real-require": "^1.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/thread-stream/node_modules/real-require": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", - "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2667,43 +1232,6 @@ "node": ">=14.0.0" } }, - "node_modules/toad-cache": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", - "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4466,15 +2994,6 @@ "engines": { "node": ">=8" } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/apps/tournament/package.json b/apps/tournament/package.json index 8ca1325..b47a0af 100644 --- a/apps/tournament/package.json +++ b/apps/tournament/package.json @@ -8,30 +8,19 @@ }, "scripts": { "dev:client": "vite", - "dev:server": "DATABASE_PATH=./tournament.sqlite node --watch --env-file-if-exists=.env --import tsx src/server/main.ts", - "build": "npm run build:client && npm run build:server", - "build:client": "vite build", - "build:server": "tsc --project tsconfig.server.json", - "start": "node dist/server/main.js", - "typecheck": "vue-tsc --noEmit --project tsconfig.client.json && tsc --noEmit --project tsconfig.server.json", - "test": "vitest run", - "test:watch": "vitest" + "build": "vite build", + "typecheck": "vue-tsc --noEmit --project tsconfig.client.json", + "test": "vitest run src/client src/shared", + "test:watch": "vitest src/client src/shared" }, "dependencies": { - "@fastify/cookie": "^11.0.2", - "@fastify/static": "^10.1.3", - "better-sqlite3": "^13.0.3", - "fastify": "^5.2.1", "vue": "^3.5.13", - "vue-router": "^4.5.0", - "zod": "^3.24.1" + "vue-router": "^4.5.0" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.5", "@vitejs/plugin-vue": "^5.2.1", "typescript": "^5.7.2", - "tsx": "^4.19.2", "vite": "^6.0.7", "vitest": "^2.1.8", "vue-tsc": "^2.2.0" diff --git a/migration/README.md b/migration/README.md index 6021016..14a05cc 100644 --- a/migration/README.md +++ b/migration/README.md @@ -166,12 +166,74 @@ reference. Both require the Phase 0 staging/Playwright setup described above. Container smoke checks emulate the existing Tournament proxy's `/jogo` prefix stripping when fetching its built asset URLs. Remote CI remains unverified. +## Phase 3 — Tournament FastAPI replacement: locally verified complete + +- Added the independent `crr-tournament` Python 3.13 workspace member, version + 1.0.0, with FastAPI, SQLAlchemy 2, Alembic and Pydantic Settings. +- Modeled the nine existing SQLite tables with typed `Mapped` columns. The frozen + Alembic baseline creates the exact Node schema and defaults. Existing databases + are validated before stamping, including column types/nullability, defaults, + primary/unique keys, checks, indexes, foreign keys, autoincrement and group + collation. Validation/stamping/upgrades use an explicit SQLite transaction; + failed upgrades roll back schema creation. Foreign keys/WAL/5000 ms timeout + apply to every connection. +- Ported calendar generation, standard/total-points standings and head-to-head + tie breakers, brackets/byes/third-place advancement, seeding and state assembly. +- Added domain-grouped request/response schemas and separate auth/public/admin + routers. CamelCase payloads, status/error shapes, Portuguese messages, HTTP + methods and confirmation rules are preserved. Request validation also covers + JavaScript-style ID coercion, name trimming and UTF-16 length limits. +- Reproduced the Node HMAC-SHA256/base64url session format and cookie semantics; + verified a token produced by the actual Node implementation. Preserved the + five-failure/15-minute login window and successful-login reset. Synchronization + preserves serial numbering and atomic login limiting under ASGI concurrency. +- Ported notification-only SSE with retry 3000, 25-second heartbeat, exact + state-change frames and subscriber cleanup. FastAPI serves the existing Vue + build, including the legacy sub-path proxy deployment behavior. +- Switched development and production runtime to Uvicorn/Python. The image builds + Vue with Node 22 and preserves UID/GID 1000 for existing SQLite volumes. Removed + Fastify, better-sqlite3, Zod, tsx and backend-only type packages/scripts from + the frontend dependency graph after compatibility checks passed. +- Updated Makefile/CI to check both Python and frontend code. Preserved all 44 + legacy HTTP/service assertions under `contracts/` and execute them unchanged + using migration-only Python transports. This compatibility command uses owned + contracts and app tooling; CI never reads/builds/runs `originals/`. + +| Check | Result | +| --- | --- | +| Backend pytest | 28 passed | +| Preserved legacy assertions against Python | 44 passed: 19 HTTP/SSE, 25 deterministic services | +| Frontend/shared tests | 13 passed | +| Vue typecheck and build | Passed | +| Fresh database schema/defaults vs Node | Exact normalized SQLite DDL match | +| Python startup with populated Node fixture | All nine table contents and public state preserved | +| Incompatible schemas / migration failure | Refused without stamping; failed DDL rolled back | +| Token compatibility / cookies / limiter | Node-produced token and HTTPS/proxy/window/reset/concurrency checks passed | +| `make migration-visual-check` | All 14 screenshots match Phase 2 byte-for-byte | +| Both Docker builds | Passed | +| Container smoke checks | Fresh Tournament, adopted Node fixture and Shifts passed; referenced resources served | +| Shifts regression suite | 16 passed | + +`phase3/compatibility.json` preserves passing legacy test names and contract +checksums; raw test reports remain in `.migration/port-compat/`. Database tests +copy the Node fixture before adoption; container adoption checks likewise use a +read-only fixture mount copied into tmpfs. No production database was used. + +The TypeScript server source/config files are retired reference material pending +Phase 6 cleanup; production images contain only the Python backend and built +Vue assets. The Vue source directory move is also deferred to that cleanup. +Compatibility transport scaffolding remains until the corresponding cleanup +exit criteria are verified. Remote GitHub Actions execution is still unverified. + +On this machine, the pyenv override described above also applies to Tournament +Makefile commands; uv still executes the applications on Python 3.13. + ## Next work 1. Verify the new CI jobs on Node 22/Python 3.13 after publishing the changes. -2. Phase 3: Tournament SQLAlchemy/Alembic adoption of the saved SQLite fixture, - then deterministic services, state, auth, routers and SSE compatibility. +2. Phase 4: Shifts service/model/security extraction and router split, preserving + routes, templates, forms, authentication, SQLite data and migration history. -Phases 3–6 are not complete. No shared Python package has been extracted yet. +Phases 4–6 are not complete. No shared Python package has been extracted yet. Independent tag-triggered release workflows and final dependency cleanup belong to later phases. diff --git a/migration/capture_screenshots.py b/migration/capture_screenshots.py index 174c6c9..d0a75be 100644 --- a/migration/capture_screenshots.py +++ b/migration/capture_screenshots.py @@ -53,6 +53,10 @@ def main(migrated: bool = False, branding: bool = False, reference: Path | None "INITIAL_ADMIN_PASSWORD": "migration-fixture-password", "SMTP_HOST": "", "ROOT_PATH": "", "SESSION_HTTPS_ONLY": "false", "PYTHONDONTWRITEBYTECODE": "1", + "ADMIN_PASSWORD": "migration-fixture-password", + "SESSION_SECRET": "migration-fixture-secret-not-for-production", + "APP_BASE_PATH": "/", "DATABASE_PATH": str(tournament_db), + "CLIENT_DIST_PATH": str(tournament_source / "dist/client"), } python = str(ROOT / ".venv/bin/python" if migrated else WORK / "shifts/.venv/bin/python") subprocess.run([python, "-m", "app.cli", "seed-demo"], cwd=shifts_source, env=env, check=True) @@ -60,8 +64,10 @@ def main(migrated: bool = False, branding: bool = False, reference: Path | None logs = [] try: for name, command, cwd in [ - ("tournament", ["node", "--import", str(tournament_source / "node_modules/tsx/dist/loader.mjs"), - "migration/capture_tournament.mjs", "--serve"], ROOT), + ("tournament", [python, "-m", "uvicorn", "app.main:create_app", "--factory", "--host", "127.0.0.1", + "--port", "18080", "--no-proxy-headers"] if migrated else + ["node", "--import", str(tournament_source / "node_modules/tsx/dist/loader.mjs"), + "migration/capture_tournament.mjs", "--serve"], ROOT / "apps/tournament/backend" if migrated else ROOT), ("shifts", [python, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "18081"], shifts_source), ]: diff --git a/migration/contracts/README.md b/migration/contracts/README.md new file mode 100644 index 0000000..af8de31 --- /dev/null +++ b/migration/contracts/README.md @@ -0,0 +1,17 @@ +# Preserved Tournament assertions + +These five test files are byte-for-byte copies of the legacy HTTP and deterministic +service tests, preserved before retiring Node backend dependencies. Their source +checksums match the Phase 0 Tournament manifest. + +`tools/check_tournament_compat.py` generates isolated test copies that replace +only implementation imports with migration transport adapters. All assertions +and scenarios run unchanged: 19 tests use actual FastAPI HTTP/SSE servers, and 25 +invoke the real Python calendar, classification, bracket and seeding services. +The adapters depend on frontend Vitest tooling and the Python backend, with no +Fastify/better-sqlite3/Zod runtime dependency and no imports from `originals/`. + +Run `make tournament-compat-test` after `make tournament-install`, or run the full +`make tournament-test`. The `--freeze` option was used once; do not overwrite +these snapshots during later refactors. Native pytest supplies database adoption, +HTTP fixture replay, authentication, validation, concurrency and SSE checks. diff --git a/migration/contracts/src/server/app.integration.test.ts b/migration/contracts/src/server/app.integration.test.ts new file mode 100644 index 0000000..f18e686 --- /dev/null +++ b/migration/contracts/src/server/app.integration.test.ts @@ -0,0 +1,1181 @@ +import type { FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { + CalendarGeneration, + CalendarGenerationPreview, + CalendarResponse, + FinalSeedPreview, + FinalStage, + Group, + LeagueMatch, + PublicTournamentState, + Team, +} from '../shared/types/tournament.js'; +import { SESSION_COOKIE_NAME } from './auth.js'; +import { createApp } from './app.js'; + +const authentication = { + adminPassword: 'integration-test-password', + sessionSecret: 'integration-test-session-secret', +}; + +let app: FastifyInstance | undefined; + +function testApp(): FastifyInstance { + if (!app) { + throw new Error('The test application has not been created.'); + } + return app; +} + +async function login(): Promise { + const response = await testApp().inject({ + method: 'POST', + url: '/api/auth/login', + payload: { password: authentication.adminPassword }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ authenticated: true }); + const session = response.cookies.find((cookie) => cookie.name === SESSION_COOKIE_NAME); + if (!session) { + throw new Error('The login response did not set a session cookie.'); + } + return `${SESSION_COOKIE_NAME}=${session.value}`; +} + +async function createTeam(session: string, number: number, name: string, groupId?: number): Promise { + if (groupId === undefined) { + const groupsResponse = await testApp().inject({ + method: 'GET', + url: '/api/admin/groups', + headers: { cookie: session }, + }); + expect(groupsResponse.statusCode).toBe(200); + const [defaultGroup] = groupsResponse.json(); + groupId = defaultGroup.id; + } + const response = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams', + headers: { cookie: session }, + payload: { number, name, groupId }, + }); + + expect(response.statusCode).toBe(201); + return response.json(); +} + +async function generatePreview(session: string, legs: 1 | 2): Promise { + const response = await testApp().inject({ + method: 'POST', + url: '/api/admin/calendar/generate-preview', + headers: { cookie: session }, + payload: { legs }, + }); + expect(response.statusCode).toBe(200); + return response.json(); +} + +async function saveGeneration( + session: string, + generation: CalendarGeneration, + confirmReplace = false, +): Promise { + const response = await testApp().inject({ + method: 'POST', + url: '/api/admin/calendar/generate', + headers: { cookie: session }, + payload: { generation, ...(confirmReplace ? { confirmReplace: true } : {}) }, + }); + expect(response.statusCode).toBe(200); + return response.json(); +} + +async function includeRoundInStandings(session: string, roundNumber: number): Promise { + const response = await testApp().inject({ + method: 'PUT', + url: `/api/admin/rounds/${roundNumber}/standings`, + headers: { cookie: session }, + payload: { countsTowardStandings: true }, + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ roundNumber, countsTowardStandings: true }); +} + +type SchedulableMatch = Pick; + +function scheduleOf(matches: readonly SchedulableMatch[]) { + return matches.map((match) => ({ + gameNumber: match.gameNumber, + roundNumber: match.roundNumber, + groupId: match.group?.id ?? null, + teamAId: match.teamA.id, + teamBId: match.teamB.id, + })); +} + +function unorderedPair(teamAId: number, teamBId: number): string { + return [teamAId, teamBId].sort((first, second) => first - second).join('-'); +} + +async function readStreamChunk(reader: ReadableStreamDefaultReader): Promise { + let timeout: ReturnType | undefined; + try { + const result = await Promise.race([ + reader.read(), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error('Timed out waiting for the SSE event.')), 2_000); + }), + ]); + return new TextDecoder().decode(result.value); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +describe('tournament manager HTTP API', () => { + beforeEach(async () => { + app = await createApp({ + databasePath: ':memory:', + authentication, + basePath: '/jogo/', + }); + await app.ready(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + app = undefined; + } + }); + + it('rejects unauthenticated administration and accepts only the configured login password', async () => { + const unauthenticated = await testApp().inject({ + method: 'GET', + url: '/api/admin/teams', + }); + expect(unauthenticated.statusCode).toBe(401); + expect(unauthenticated.json()).toEqual({ error: expect.any(String) }); + + const invalidLogin = await testApp().inject({ + method: 'POST', + url: '/api/auth/login', + payload: { password: 'incorrect-password' }, + }); + expect(invalidLogin.statusCode).toBe(401); + expect(invalidLogin.json()).toEqual({ error: expect.any(String) }); + + const session = await login(); + const authenticatedSession = await testApp().inject({ + method: 'GET', + url: '/api/auth/session', + headers: { cookie: session }, + }); + expect(authenticatedSession.statusCode).toBe(200); + expect(authenticatedSession.json()).toEqual({ authenticated: true }); + }); + + it('scopes the administration session cookie to the deployment path', async () => { + const response = await testApp().inject({ + method: 'POST', + url: '/api/auth/login', + payload: { password: authentication.adminPassword }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['set-cookie']).toContain('Path=/jogo/'); + }); + + it('publishes the admin-selected panel and zoom to the public display state', async () => { + const initialPublicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(initialPublicState.statusCode).toBe(200); + expect(initialPublicState.json().display).toEqual({ activePanel: 'latest-results', zoomPercent: 100 }); + + const unauthenticated = await testApp().inject({ + method: 'PUT', + url: '/api/admin/display', + payload: { activePanel: 'classification', zoomPercent: 137 }, + }); + expect(unauthenticated.statusCode).toBe(401); + + const session = await login(); + const invalid = await testApp().inject({ + method: 'PUT', + url: '/api/admin/display', + headers: { cookie: session }, + payload: { activePanel: 'unknown-panel', zoomPercent: 137 }, + }); + expect(invalid.statusCode).toBe(400); + + const invalidZoom = await testApp().inject({ + method: 'PUT', + url: '/api/admin/display', + headers: { cookie: session }, + payload: { activePanel: 'classification', zoomPercent: 401 }, + }); + expect(invalidZoom.statusCode).toBe(400); + + const updated = await testApp().inject({ + method: 'PUT', + url: '/api/admin/display', + headers: { cookie: session }, + payload: { activePanel: 'classification', zoomPercent: 137 }, + }); + expect(updated.statusCode).toBe(200); + expect(updated.json()).toEqual({ activePanel: 'classification', zoomPercent: 137 }); + + const adminSettings = await testApp().inject({ + method: 'GET', + url: '/api/admin/display', + headers: { cookie: session }, + }); + expect(adminSettings.statusCode).toBe(200); + expect(adminSettings.json()).toEqual({ activePanel: 'classification', zoomPercent: 137 }); + + const publicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(publicState.statusCode).toBe(200); + expect(publicState.json().display).toEqual({ activePanel: 'classification', zoomPercent: 137 }); + }); + + it('notifies connected displays when the selected panel changes', async () => { + const session = await login(); + await testApp().listen({ host: '127.0.0.1', port: 0 }); + const address = testApp().server.address(); + if (!address || typeof address === 'string') throw new Error('The test server did not expose a TCP address.'); + + const controller = new AbortController(); + const response = await fetch(`http://127.0.0.1:${address.port}/api/public/events`, { signal: controller.signal }); + expect(response.status).toBe(200); + const reader = response.body?.getReader(); + if (!reader) throw new Error('The SSE response did not expose a readable stream.'); + + try { + expect(await readStreamChunk(reader)).toContain('retry: 3000'); + + const updated = await testApp().inject({ + method: 'PUT', + url: '/api/admin/display', + headers: { cookie: session }, + payload: { activePanel: 'classification', zoomPercent: 137 }, + }); + expect(updated.statusCode).toBe(200); + expect(await readStreamChunk(reader)).toContain('event: state-changed'); + } finally { + await reader.cancel(); + controller.abort(); + } + }); + + it('creates teams in Grupo A and rejects globally duplicated team numbers', async () => { + const session = await login(); + const created = await createTeam(session, 10, 'Falcons'); + expect(created).toMatchObject({ + id: expect.any(Number), + number: 10, + name: 'Falcons', + group: expect.objectContaining({ id: expect.any(Number), name: 'Grupo A', sortOrder: 0 }), + players: [], + }); + + const createdGroup = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'Grupo B' }, + }); + expect(createdGroup.statusCode).toBe(201); + const groupB = createdGroup.json(); + + const duplicate = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams', + headers: { cookie: session }, + payload: { number: 10, name: 'Duplicate Falcons', groupId: groupB.id }, + }); + expect(duplicate.statusCode).toBe(409); + expect(duplicate.json()).toEqual({ error: expect.any(String) }); + + const moved = await testApp().inject({ + method: 'PUT', + url: `/api/admin/teams/${created.id}`, + headers: { cookie: session }, + payload: { number: 10, name: 'Falcons', groupId: groupB.id }, + }); + expect(moved.statusCode).toBe(200); + expect(moved.json()).toMatchObject({ id: created.id, group: groupB }); + + const withoutGroup = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams', + headers: { cookie: session }, + payload: { number: 11, name: 'No Group' }, + }); + expect(withoutGroup.statusCode).toBe(400); + + const unknownGroup = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams', + headers: { cookie: session }, + payload: { number: 11, name: 'Unknown Group', groupId: 99_999 }, + }); + expect(unknownGroup.statusCode).toBe(404); + }); + + it('assigns the next team number when creating a team without one', async () => { + const session = await login(); + const groupsResponse = await testApp().inject({ + method: 'GET', + url: '/api/admin/groups', + headers: { cookie: session }, + }); + const [group] = groupsResponse.json(); + + const first = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams', + headers: { cookie: session }, + payload: { name: 'Primeira', groupId: group.id }, + }); + const second = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams', + headers: { cookie: session }, + payload: { name: 'Segunda', groupId: group.id }, + }); + + expect(first.statusCode).toBe(201); + expect(first.json()).toMatchObject({ number: 1, name: 'Primeira' }); + expect(second.statusCode).toBe(201); + expect(second.json()).toMatchObject({ number: 2, name: 'Segunda' }); + }); + + it('manages groups and prevents deleting nonempty or final groups', async () => { + const session = await login(); + const initial = await testApp().inject({ + method: 'GET', + url: '/api/admin/groups', + headers: { cookie: session }, + }); + expect(initial.statusCode).toBe(200); + const [groupA] = initial.json(); + expect(groupA).toMatchObject({ name: 'Grupo A' }); + + const emptyName = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: ' ' }, + }); + expect(emptyName.statusCode).toBe(400); + + const created = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'Grupo B' }, + }); + expect(created.statusCode).toBe(201); + const groupB = created.json(); + + const duplicate = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'grupo b' }, + }); + expect(duplicate.statusCode).toBe(409); + + const renamed = await testApp().inject({ + method: 'PUT', + url: `/api/admin/groups/${groupB.id}`, + headers: { cookie: session }, + payload: { name: 'Grupo Norte' }, + }); + expect(renamed.statusCode).toBe(200); + expect(renamed.json()).toEqual({ id: groupB.id, name: 'Grupo Norte', sortOrder: groupB.sortOrder }); + + const deleted = await testApp().inject({ + method: 'DELETE', + url: `/api/admin/groups/${groupB.id}`, + headers: { cookie: session }, + }); + expect(deleted.statusCode).toBe(200); + + const finalGroup = await testApp().inject({ + method: 'DELETE', + url: `/api/admin/groups/${groupA.id}`, + headers: { cookie: session }, + }); + expect(finalGroup.statusCode).toBe(409); + expect(finalGroup.json()).toEqual({ error: 'Não é possível eliminar o último grupo.' }); + + const anotherGroup = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'Grupo Sul' }, + }); + expect(anotherGroup.statusCode).toBe(201); + + await createTeam(session, 1, 'Lions'); + const nonempty = await testApp().inject({ + method: 'DELETE', + url: `/api/admin/groups/${groupA.id}`, + headers: { cookie: session }, + }); + expect(nonempty.statusCode).toBe(409); + expect(nonempty.json()).toEqual({ error: 'Não é possível eliminar um grupo com equipas.' }); + }); + + it('previews and saves the exact one-leg schedule for four teams', async () => { + const session = await login(); + const teams = [ + await createTeam(session, 1, 'Lions'), + await createTeam(session, 2, 'Tigers'), + await createTeam(session, 3, 'Bears'), + await createTeam(session, 4, 'Wolves'), + ]; + + const preview = await generatePreview(session, 1); + expect(preview).toMatchObject({ matchCount: 6, roundCount: 3 }); + expect(preview.generation.legs).toBe(1); + expect([...preview.generation.teamOrder].sort((first, second) => first.teamId - second.teamId)).toEqual( + teams + .map((team) => ({ teamId: team.id, groupId: team.group.id })) + .sort((first, second) => first.teamId - second.teamId), + ); + + const [firstId, secondId, thirdId, fourthId] = preview.generation.teamOrder.map(({ teamId }) => teamId); + const groupId = teams[0].group.id; + expect(scheduleOf(preview.matches)).toEqual([ + { gameNumber: 1, roundNumber: 1, groupId, teamAId: firstId, teamBId: fourthId }, + { gameNumber: 2, roundNumber: 1, groupId, teamAId: secondId, teamBId: thirdId }, + { gameNumber: 3, roundNumber: 2, groupId, teamAId: thirdId, teamBId: firstId }, + { gameNumber: 4, roundNumber: 2, groupId, teamAId: fourthId, teamBId: secondId }, + { gameNumber: 5, roundNumber: 3, groupId, teamAId: firstId, teamBId: secondId }, + { gameNumber: 6, roundNumber: 3, groupId, teamAId: thirdId, teamBId: fourthId }, + ]); + + const expectedPairs = teams.flatMap((team, index) => ( + teams.slice(index + 1).map((opponent) => unorderedPair(team.id, opponent.id)) + )).sort(); + expect(preview.matches.map((match) => unorderedPair(match.teamA.id, match.teamB.id)).sort()).toEqual(expectedPairs); + for (const roundNumber of [1, 2, 3]) { + const participants = preview.matches + .filter((match) => match.roundNumber === roundNumber) + .flatMap((match) => [match.teamA.id, match.teamB.id]); + expect(new Set(participants).size).toBe(participants.length); + } + + const saved = await saveGeneration(session, preview.generation); + expect(saved).toMatchObject({ matchCount: 6, roundCount: 3 }); + expect(scheduleOf(saved.matches)).toEqual(scheduleOf(preview.matches)); + expect(saved.matches.every((match) => match.scoreA === null && match.scoreB === null)).toBe(true); + + const persisted = await testApp().inject({ + method: 'GET', + url: '/api/admin/calendar', + headers: { cookie: session }, + }); + expect(persisted.statusCode).toBe(200); + expect(persisted.json()).toEqual(saved); + }); + + it('rejects generation when no group can produce a match', async () => { + const session = await login(); + await createTeam(session, 1, 'Only team'); + + const response = await testApp().inject({ + method: 'POST', + url: '/api/admin/calendar/generate-preview', + headers: { cookie: session }, + payload: { legs: 1 }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json()).toEqual({ + error: 'São necessárias pelo menos duas equipas no mesmo grupo para gerar o calendário.', + }); + }); + + it('keeps a round out of the standings until an administrator enables it', async () => { + const session = await login(); + await createTeam(session, 1, 'Lions'); + await createTeam(session, 2, 'Tigers'); + const preview = await generatePreview(session, 1); + const [match] = (await saveGeneration(session, preview.generation)).matches; + expect(match.countsTowardStandings).toBe(false); + + const result = await testApp().inject({ + method: 'PUT', + url: `/api/admin/matches/${match.id}/result`, + headers: { cookie: session }, + payload: { scoreA: 4, scoreB: 1 }, + }); + expect(result.statusCode).toBe(200); + expect(result.json()).toMatchObject({ + id: match.id, + scoreA: 4, + scoreB: 1, + }); + + const publicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(publicState.statusCode).toBe(200); + const disabledState = publicState.json(); + expect(disabledState.matches).toEqual([ + expect.objectContaining({ id: match.id, scoreA: 4, scoreB: 1, countsTowardStandings: false }), + ]); + expect(disabledState.groupStandings[0].rows).toEqual(expect.arrayContaining([ + expect.objectContaining({ team: expect.objectContaining({ id: match.teamA.id }), played: 0, points: 0 }), + expect.objectContaining({ team: expect.objectContaining({ id: match.teamB.id }), played: 0, points: 0 }), + ])); + + await includeRoundInStandings(session, match.roundNumber); + + const enabledPublicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(enabledPublicState.statusCode).toBe(200); + expect(enabledPublicState.json()).toMatchObject({ + matches: [expect.objectContaining({ id: match.id, countsTowardStandings: true })], + groupStandings: [{ + group: expect.objectContaining({ name: 'Grupo A' }), + rows: [ + expect.objectContaining({ + position: 1, + team: expect.objectContaining({ id: match.teamA.id }), + played: 1, + wins: 1, + draws: 0, + losses: 0, + goalsFor: 4, + goalsAgainst: 1, + goalDifference: 3, + points: 3, + }), + expect.objectContaining({ + position: 2, + team: expect.objectContaining({ id: match.teamB.id }), + played: 1, + wins: 0, + draws: 0, + losses: 1, + goalsFor: 1, + goalsAgainst: 4, + goalDifference: -3, + points: 0, + }), + ], + }], + }); + }); + + it('ranks total game points and resolves equal totals by the direct match', async () => { + const session = await login(); + const teamA = await createTeam(session, 1, 'Lions'); + const teamB = await createTeam(session, 2, 'Tigers'); + const teamC = await createTeam(session, 3, 'Bears'); + const preview = await generatePreview(session, 1); + const calendar = await saveGeneration(session, preview.generation); + for (const roundNumber of new Set(calendar.matches.map((match) => match.roundNumber))) { + await includeRoundInStandings(session, roundNumber); + } + + const settings = await testApp().inject({ + method: 'PUT', + url: '/api/admin/settings', + headers: { cookie: session }, + payload: { name: 'Torneio', classificationMode: 'total-points' }, + }); + expect(settings.statusCode).toBe(200); + expect(settings.json()).toMatchObject({ classificationMode: 'total-points' }); + + const results = [ + { teams: [teamA, teamB], scores: new Map([[teamA.id, 1], [teamB.id, 0]]) }, + { teams: [teamA, teamC], scores: new Map([[teamA.id, 4], [teamC.id, 10]]) }, + { teams: [teamB, teamC], scores: new Map([[teamB.id, 5], [teamC.id, 0]]) }, + ]; + for (const expected of results) { + const pair = unorderedPair(expected.teams[0].id, expected.teams[1].id); + const match = calendar.matches.find((candidate) => ( + unorderedPair(candidate.teamA.id!, candidate.teamB.id!) === pair + )); + expect(match).toBeDefined(); + const response = await testApp().inject({ + method: 'PUT', + url: `/api/admin/matches/${match!.id}/result`, + headers: { cookie: session }, + payload: { + scoreA: expected.scores.get(match!.teamA.id!)!, + scoreB: expected.scores.get(match!.teamB.id!)!, + }, + }); + expect(response.statusCode).toBe(200); + } + + const publicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(publicState.statusCode).toBe(200); + const state = publicState.json(); + expect(state.tournament.classificationMode).toBe('total-points'); + expect(state.groupStandings[0].rows.map((row) => ({ + teamId: row.team.id, + points: row.points, + goalDifference: row.goalDifference, + }))).toEqual([ + { teamId: teamC.id, points: 10, goalDifference: 1 }, + { teamId: teamA.id, points: 5, goalDifference: -5 }, + { teamId: teamB.id, points: 5, goalDifference: 4 }, + ]); + }); + + it('generates only intra-group games with persisted rounds and aligned public classifications', async () => { + const session = await login(); + const groups = await testApp().inject({ + method: 'GET', + url: '/api/admin/groups', + headers: { cookie: session }, + }); + const [groupA] = groups.json(); + const createdGroup = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'Grupo B' }, + }); + const groupB = createdGroup.json(); + const groupATeams = [ + await createTeam(session, 1, 'Lions', groupA.id), + await createTeam(session, 2, 'Tigers', groupA.id), + await createTeam(session, 3, 'Bears', groupA.id), + ]; + const groupBTeams = [ + await createTeam(session, 4, 'Wolves', groupB.id), + await createTeam(session, 5, 'Eagles', groupB.id), + ]; + const allTeams = [...groupATeams, ...groupBTeams]; + + const preview = await generatePreview(session, 1); + expect(preview).toMatchObject({ matchCount: 4, roundCount: 3 }); + expect(preview.matches.map((match) => match.roundNumber)).toEqual([1, 1, 2, 3]); + expect(preview.matches.every((match) => ( + match.group.id === match.teamA.group.id && match.group.id === match.teamB.group.id + ))).toBe(true); + + const expectedPairs = [groupATeams, groupBTeams].flatMap((groupTeams) => ( + groupTeams.flatMap((team, index) => ( + groupTeams.slice(index + 1).map((opponent) => unorderedPair(team.id, opponent.id)) + )) + )).sort(); + expect(preview.matches.map((match) => unorderedPair(match.teamA.id, match.teamB.id)).sort()).toEqual(expectedPairs); + + const calendar = await saveGeneration(session, preview.generation); + expect(scheduleOf(calendar.matches)).toEqual(scheduleOf(preview.matches)); + for (const roundNumber of new Set(calendar.matches.map((match) => match.roundNumber))) { + await includeRoundInStandings(session, roundNumber); + } + + const attemptedReassignment = await testApp().inject({ + method: 'PUT', + url: `/api/admin/teams/${groupATeams[0].id}`, + headers: { cookie: session }, + payload: { number: groupATeams[0].number, name: groupATeams[0].name, groupId: groupB.id }, + }); + expect(attemptedReassignment.statusCode).toBe(409); + expect(attemptedReassignment.json()).toEqual({ + error: 'Não é possível alterar o grupo de uma equipa utilizada no calendário. Limpe ou substitua o calendário primeiro.', + }); + + for (const match of calendar.matches) { + const result = await testApp().inject({ + method: 'PUT', + url: `/api/admin/matches/${match.id}/result`, + headers: { cookie: session }, + payload: { scoreA: 2, scoreB: 0 }, + }); + expect(result.statusCode).toBe(200); + } + + const publicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(publicState.statusCode).toBe(200); + const state = publicState.json(); + expect(state.groups).toEqual([groupA, groupB]); + expect(scheduleOf(state.matches)).toEqual(scheduleOf(calendar.matches)); + expect(state.matches.every((match) => match.scoreA === 2 && match.scoreB === 0)).toBe(true); + + for (const standing of state.groupStandings) { + const groupTeams = allTeams.filter((team) => team.group.id === standing.group.id); + const groupMatches = state.matches.filter((match) => match.group?.id === standing.group.id); + expect(standing.rows.map((row) => row.team.id).sort((first, second) => first - second)).toEqual( + groupTeams.map((team) => team.id).sort((first, second) => first - second), + ); + for (const row of standing.rows) { + const wins = groupMatches.filter((match) => match.teamA.id === row.team.id).length; + const losses = groupMatches.filter((match) => match.teamB.id === row.team.id).length; + expect(row).toMatchObject({ + played: wins + losses, + wins, + draws: 0, + losses, + goalsFor: wins * 2, + goalsAgainst: losses * 2, + goalDifference: (wins - losses) * 2, + points: wins * 3, + }); + } + expect(standing.rows.reduce((total, row) => total + row.played, 0)).toBe(groupMatches.length * 2); + } + }); + + it('previews a second leg that exactly mirrors the first with reversed venues', async () => { + const session = await login(); + await createTeam(session, 1, 'Lions'); + await createTeam(session, 2, 'Tigers'); + await createTeam(session, 3, 'Bears'); + await createTeam(session, 4, 'Wolves'); + + const preview = await generatePreview(session, 2); + expect(preview).toMatchObject({ matchCount: 12, roundCount: 6 }); + expect(preview.generation.legs).toBe(2); + + const firstLegMatchCount = preview.matchCount / 2; + const roundOffset = preview.roundCount / 2; + const firstLeg = preview.matches.slice(0, firstLegMatchCount); + const secondLeg = preview.matches.slice(firstLegMatchCount); + expect(scheduleOf(secondLeg)).toEqual(firstLeg.map((match, index) => ({ + gameNumber: firstLegMatchCount + index + 1, + roundNumber: match.roundNumber + roundOffset, + groupId: match.group.id, + teamAId: match.teamB.id, + teamBId: match.teamA.id, + }))); + }); + + it('rejects tampered and stale calendar generation descriptors', async () => { + const session = await login(); + await createTeam(session, 1, 'Lions'); + await createTeam(session, 2, 'Tigers'); + await createTeam(session, 3, 'Bears'); + await createTeam(session, 4, 'Wolves'); + const preview = await generatePreview(session, 1); + + const tamperedTeamOrder = preview.generation.teamOrder.map((entry, index) => ( + index === 1 ? { ...entry, ...preview.generation.teamOrder[0] } : entry + )); + const tampered = await testApp().inject({ + method: 'POST', + url: '/api/admin/calendar/generate', + headers: { cookie: session }, + payload: { generation: { ...preview.generation, teamOrder: tamperedTeamOrder } }, + }); + expect(tampered.statusCode).toBe(409); + expect(tampered.json()).toEqual({ + error: 'As equipas ou os grupos foram alterados. Sorteie novamente o calendário.', + }); + + await createTeam(session, 5, 'Eagles'); + const stale = await testApp().inject({ + method: 'POST', + url: '/api/admin/calendar/generate', + headers: { cookie: session }, + payload: { generation: preview.generation }, + }); + expect(stale.statusCode).toBe(409); + expect(stale.json()).toEqual({ error: 'As equipas foram alteradas. Sorteie novamente o calendário.' }); + }); + + it('requires confirmation to replace a scored calendar and clears scores when confirmed', async () => { + const session = await login(); + await createTeam(session, 1, 'Lions'); + await createTeam(session, 2, 'Tigers'); + await createTeam(session, 3, 'Bears'); + await createTeam(session, 4, 'Wolves'); + + const initialPreview = await generatePreview(session, 1); + const initialCalendar = await saveGeneration(session, initialPreview.generation); + const scoredMatch = initialCalendar.matches[0]; + const result = await testApp().inject({ + method: 'PUT', + url: `/api/admin/matches/${scoredMatch.id}/result`, + headers: { cookie: session }, + payload: { scoreA: 3, scoreB: 1 }, + }); + expect(result.statusCode).toBe(200); + + const replacementPreview = await generatePreview(session, 1); + const unconfirmed = await testApp().inject({ + method: 'POST', + url: '/api/admin/calendar/generate', + headers: { cookie: session }, + payload: { generation: replacementPreview.generation }, + }); + expect(unconfirmed.statusCode).toBe(409); + expect(unconfirmed.json()).toEqual({ + error: 'Já existem resultados registados. Confirme a substituição do calendário para eliminar esses resultados.', + }); + + const unchanged = await testApp().inject({ + method: 'GET', + url: '/api/admin/calendar', + headers: { cookie: session }, + }); + expect(unchanged.statusCode).toBe(200); + expect(unchanged.json().matches.find((match) => match.id === scoredMatch.id)).toMatchObject({ + scoreA: 3, + scoreB: 1, + }); + + const replaced = await saveGeneration(session, replacementPreview.generation, true); + expect(scheduleOf(replaced.matches)).toEqual(scheduleOf(replacementPreview.matches)); + expect(replaced.matches.every((match) => match.scoreA === null && match.scoreB === null)).toBe(true); + }); + + it('randomizes existing team numbers and group assignments after confirmation', async () => { + const session = await login(); + const groupsResponse = await testApp().inject({ + method: 'GET', + url: '/api/admin/groups', + headers: { cookie: session }, + }); + const [groupA] = groupsResponse.json(); + const groupBResponse = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'Grupo B' }, + }); + const groupB = groupBResponse.json(); + const teams = await Promise.all([ + createTeam(session, 11, 'Lions'), + createTeam(session, 24, 'Tigers'), + createTeam(session, 37, 'Bears'), + ]); + const preview = await generatePreview(session, 1); + const scheduled = await saveGeneration(session, preview.generation); + expect(scheduled.matches.length).toBeGreaterThan(0); + + const unconfirmed = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams/randomize', + headers: { cookie: session }, + payload: {}, + }); + expect(unconfirmed.statusCode).toBe(409); + + const randomized = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams/randomize', + headers: { cookie: session }, + payload: { confirm: true }, + }); + expect(randomized.statusCode).toBe(200); + const result = randomized.json<{ teams: Team[]; calendarCleared: boolean }>(); + expect(result.calendarCleared).toBe(true); + expect(result.teams.map((team) => team.number).sort((a, b) => a - b)).toEqual([11, 24, 37]); + expect(new Set(result.teams.map((team) => team.number)).size).toBe(3); + expect(result.teams.map((team) => team.id).sort((a, b) => a - b)).toEqual(teams.map((team) => team.id).sort((a, b) => a - b)); + expect(result.teams.map((team) => team.group.id)).toEqual(expect.arrayContaining([groupA.id, groupB.id])); + expect(Math.abs( + result.teams.filter((team) => team.group.id === groupA.id).length - + result.teams.filter((team) => team.group.id === groupB.id).length, + )).toBeLessThanOrEqual(1); + + const calendar = await testApp().inject({ + method: 'GET', + url: '/api/admin/calendar', + headers: { cookie: session }, + }); + expect(calendar.json().matches).toEqual([]); + }); + + it('configures and advances a final stage that is exposed through public state', async () => { + const session = await login(); + const lions = await createTeam(session, 1, 'Lions'); + const tigers = await createTeam(session, 2, 'Tigers'); + const bears = await createTeam(session, 3, 'Bears'); + const wolves = await createTeam(session, 4, 'Wolves'); + + const invalidThirdPlace = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/config', + headers: { cookie: session }, + payload: { roundCount: 1, thirdPlaceEnabled: true }, + }); + expect(invalidThirdPlace.statusCode).toBe(400); + expect(invalidThirdPlace.json()).toEqual({ error: 'O jogo do 3.º lugar requer pelo menos duas rondas.' }); + + const configured = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/config', + headers: { cookie: session }, + payload: { roundCount: 2, thirdPlaceEnabled: true }, + }); + expect(configured.statusCode).toBe(200); + expect(configured.json()).toMatchObject({ + roundCount: 2, + thirdPlaceEnabled: true, + champion: null, + rounds: [ + { roundIndex: 1, name: 'Meias-finais', matches: [{ matchIndex: 1 }, { matchIndex: 2 }] }, + { roundIndex: 2, name: 'Final', matches: [{ matchIndex: 1 }] }, + ], + thirdPlaceMatch: { matchIndex: 2 }, + }); + + const seeded = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/seeds', + headers: { cookie: session }, + payload: { + seeds: [ + { slotIndex: 1, teamId: lions.id }, + { slotIndex: 2, teamId: tigers.id }, + { slotIndex: 3, teamId: bears.id }, + { slotIndex: 4, teamId: wolves.id }, + ], + }, + }); + expect(seeded.statusCode).toBe(200); + expect(seeded.json().rounds[0].matches).toMatchObject([ + { teamA: { team: { id: lions.id } }, teamB: { team: { id: tigers.id } } }, + { teamA: { team: { id: bears.id } }, teamB: { team: { id: wolves.id } } }, + ]); + + const firstSemiFinal = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/matches/1/1/result', + headers: { cookie: session }, + payload: { scoreA: 3, scoreB: 1 }, + }); + expect(firstSemiFinal.statusCode).toBe(200); + expect(firstSemiFinal.json().rounds[1].matches[0]).toMatchObject({ + teamA: { team: { id: lions.id }, pending: false }, + teamB: { team: null, pending: true }, + }); + + const secondSemiFinal = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/matches/1/2/result', + headers: { cookie: session }, + payload: { scoreA: 1, scoreB: 3 }, + }); + expect(secondSemiFinal.statusCode).toBe(200); + expect(secondSemiFinal.json().rounds[1].matches[0]).toMatchObject({ + teamA: { team: { id: lions.id } }, + teamB: { team: { id: wolves.id } }, + }); + + const thirdPlace = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/matches/2/2/result', + headers: { cookie: session }, + payload: { scoreA: 1, scoreB: 3 }, + }); + expect(thirdPlace.statusCode).toBe(200); + expect(thirdPlace.json().thirdPlaceMatch).toMatchObject({ + teamA: { team: { id: tigers.id } }, + teamB: { team: { id: bears.id } }, + scoreA: 1, + scoreB: 3, + winner: { id: bears.id }, + }); + + const final = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/matches/2/1/result', + headers: { cookie: session }, + payload: { scoreA: 2, scoreB: 1 }, + }); + expect(final.statusCode).toBe(200); + const completedFinalStage = final.json(); + expect(completedFinalStage.champion).toMatchObject({ id: lions.id, number: 1, name: 'Lions' }); + + const publicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(publicState.statusCode).toBe(200); + expect(publicState.json().finalStage).toEqual(completedFinalStage); + + const correctedSemiFinal = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/matches/1/1/result', + headers: { cookie: session }, + payload: { scoreA: 1, scoreB: 3 }, + }); + expect(correctedSemiFinal.statusCode).toBe(200); + const correctedFinalStage = correctedSemiFinal.json(); + expect(correctedFinalStage.champion).toBeNull(); + expect(correctedFinalStage.rounds[0].matches[0]).toMatchObject({ winner: { id: tigers.id } }); + expect(correctedFinalStage.rounds[1].matches[0]).toMatchObject({ + teamA: { team: { id: tigers.id } }, + teamB: { team: { id: wolves.id } }, + scoreA: null, + scoreB: null, + winner: null, + }); + expect(correctedFinalStage.thirdPlaceMatch).toMatchObject({ + teamA: { team: { id: lions.id } }, + teamB: { team: { id: bears.id } }, + scoreA: null, + scoreB: null, + winner: null, + }); + + const correctedPublicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(correctedPublicState.statusCode).toBe(200); + expect(correctedPublicState.json().finalStage?.champion).toBeNull(); + + const unconfirmedClear = await testApp().inject({ + method: 'DELETE', + url: '/api/admin/final-stage/config', + headers: { cookie: session }, + payload: {}, + }); + expect(unconfirmedClear.statusCode).toBe(409); + + const cleared = await testApp().inject({ + method: 'DELETE', + url: '/api/admin/final-stage/config', + headers: { cookie: session }, + payload: { confirm: true }, + }); + expect(cleared.statusCode).toBe(200); + expect(cleared.json()).toEqual({ + roundCount: null, + thirdPlaceEnabled: false, + rounds: [], + thirdPlaceMatch: null, + champion: null, + }); + + const clearedPublicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(clearedPublicState.statusCode).toBe(200); + expect(clearedPublicState.json().finalStage).toEqual({ + roundCount: null, + thirdPlaceEnabled: false, + rounds: [], + thirdPlaceMatch: null, + champion: null, + }); + }); + + it('accepts the next-match display panel', async () => { + const session = await login(); + const updated = await testApp().inject({ + method: 'PUT', + url: '/api/admin/display', + headers: { cookie: session }, + payload: { activePanel: 'next-match', zoomPercent: 100 }, + }); + + expect(updated.statusCode).toBe(200); + expect(updated.json()).toEqual({ activePanel: 'next-match', zoomPercent: 100 }); + + const publicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(publicState.json().display.activePanel).toBe('next-match'); + }); + + it('previews automatic final-stage qualification from group standings', async () => { + const session = await login(); + const groupsResponse = await testApp().inject({ + method: 'GET', + url: '/api/admin/groups', + headers: { cookie: session }, + }); + const [groupA] = groupsResponse.json(); + const groupBResponse = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'Grupo B' }, + }); + const groupB = groupBResponse.json(); + + const a1 = await createTeam(session, 1, 'A1', groupA.id); + const a2 = await createTeam(session, 2, 'A2', groupA.id); + const b1 = await createTeam(session, 3, 'B1', groupB.id); + const b2 = await createTeam(session, 4, 'B2', groupB.id); + + const preview = await generatePreview(session, 1); + const calendar = await saveGeneration(session, preview.generation); + for (const roundNumber of new Set(calendar.matches.map((match) => match.roundNumber))) { + await includeRoundInStandings(session, roundNumber); + } + + for (const match of calendar.matches) { + const preferredWinner = match.group?.id === groupA.id ? a1.id : b1.id; + const response = await testApp().inject({ + method: 'PUT', + url: `/api/admin/matches/${match.id}/result`, + headers: { cookie: session }, + payload: { + scoreA: match.teamA.id === preferredWinner ? 3 : 1, + scoreB: match.teamB.id === preferredWinner ? 3 : 1, + }, + }); + expect(response.statusCode).toBe(200); + } + + const configured = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/config', + headers: { cookie: session }, + payload: { roundCount: 2, thirdPlaceEnabled: false }, + }); + expect(configured.statusCode).toBe(200); + + const autoPreview = await testApp().inject({ + method: 'POST', + url: '/api/admin/final-stage/auto-seed-preview', + headers: { cookie: session }, + payload: { mode: 'per-group', qualifiersPerGroup: 2 }, + }); + expect(autoPreview.statusCode).toBe(200); + expect(autoPreview.json()).toMatchObject({ + mode: 'per-group', + qualifiersPerGroup: 2, + qualifierCount: 4, + qualifiers: [ + { seedNumber: 1, groupPosition: 1, team: { id: a1.id } }, + { seedNumber: 2, groupPosition: 1, team: { id: b1.id } }, + { seedNumber: 3, groupPosition: 2, team: { id: a2.id } }, + { seedNumber: 4, groupPosition: 2, team: { id: b2.id } }, + ], + seeds: [ + { slotIndex: 1, teamId: a1.id }, + { slotIndex: 2, teamId: b2.id }, + { slotIndex: 3, teamId: b1.id }, + { slotIndex: 4, teamId: a2.id }, + ], + }); + + const tooMany = await testApp().inject({ + method: 'POST', + url: '/api/admin/final-stage/auto-seed-preview', + headers: { cookie: session }, + payload: { mode: 'per-group', qualifiersPerGroup: 3 }, + }); + expect(tooMany.statusCode).toBe(400); + + const overallPreview = await testApp().inject({ + method: 'POST', + url: '/api/admin/final-stage/auto-seed-preview', + headers: { cookie: session }, + payload: { mode: 'overall', qualifierCount: 2 }, + }); + expect(overallPreview.statusCode).toBe(200); + expect(overallPreview.json()).toMatchObject({ + mode: 'overall', + qualifiersPerGroup: null, + qualifierCount: 2, + qualifiers: [ + { seedNumber: 1, team: { id: a1.id } }, + { seedNumber: 2, team: { id: b1.id } }, + ], + seeds: [ + { slotIndex: 1, teamId: a1.id }, + { slotIndex: 2, teamId: null }, + { slotIndex: 3, teamId: b1.id }, + { slotIndex: 4, teamId: null }, + ], + }); + }); + +}); diff --git a/migration/contracts/src/server/services/bracket.test.ts b/migration/contracts/src/server/services/bracket.test.ts new file mode 100644 index 0000000..704c3c7 --- /dev/null +++ b/migration/contracts/src/server/services/bracket.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import type { Group, TeamSummary } from '../../shared/types/tournament.js'; +import { calculateBracket, downstreamMatches } from './bracket.js'; + +const group: Group = { id: 1, name: 'Grupo A', sortOrder: 0 }; +const team = (id: number): TeamSummary => ({ id, number: id, name: `Equipa ${id}`, group }); +const seeds = [1, 2, 3, 4].map((slotIndex) => ({ slotIndex, team: team(slotIndex) })); + +describe('calculateBracket', () => { + it('faz avançar vencedores entre várias rondas e determina o campeão', () => { + const bracket = calculateBracket(2, seeds, [ + { roundIndex: 1, matchIndex: 1, scoreA: 5, scoreB: 2 }, + { roundIndex: 1, matchIndex: 2, scoreA: 1, scoreB: 3 }, + { roundIndex: 2, matchIndex: 1, scoreA: 4, scoreB: 2 }, + ]); + expect(bracket.rounds[1].matches[0].teamA.team?.id).toBe(1); + expect(bracket.rounds[1].matches[0].teamB.team?.id).toBe(4); + expect(bracket.champion?.id).toBe(1); + }); + + it('não faz avançar um empate que a API deve rejeitar', () => { + const bracket = calculateBracket(1, seeds.slice(0, 2), [ + { roundIndex: 1, matchIndex: 1, scoreA: 3, scoreB: 3 }, + ]); + expect(bracket.rounds[0].matches[0].winner).toBeNull(); + expect(bracket.champion).toBeNull(); + }); + + it('faz avançar uma equipa automaticamente num bye e deixa jogos vazios por apurar', () => { + const bracket = calculateBracket(2, [ + { slotIndex: 1, team: team(1) }, + { slotIndex: 2, team: null }, + { slotIndex: 3, team: null }, + { slotIndex: 4, team: null }, + ], []); + expect(bracket.rounds[0].matches[0]).toMatchObject({ isBye: true, winner: team(1) }); + expect(bracket.rounds[0].matches[1].winner).toBeNull(); + expect(bracket.rounds[1].matches[0].teamA.team?.id).toBe(1); + expect(bracket.rounds[1].matches[0].teamB.pending).toBe(false); + }); + + it('cria o jogo do 3.º lugar com os derrotados das meias-finais', () => { + const bracket = calculateBracket(2, seeds, [ + { roundIndex: 1, matchIndex: 1, scoreA: 5, scoreB: 2 }, + { roundIndex: 1, matchIndex: 2, scoreA: 1, scoreB: 3 }, + { roundIndex: 2, matchIndex: 2, scoreA: 2, scoreB: 4 }, + ], true); + expect(bracket.thirdPlaceEnabled).toBe(true); + expect(bracket.thirdPlaceMatch).toMatchObject({ + matchIndex: 2, + teamA: { team: { id: 2 } }, + teamB: { team: { id: 3 } }, + scoreA: 2, + scoreB: 4, + winner: { id: 3 }, + }); + }); + + it('identifica todos os resultados a invalidar após mudar um jogo a montante', () => { + expect(downstreamMatches(4, 1, 3)).toEqual([ + { roundIndex: 2, matchIndex: 2 }, + { roundIndex: 3, matchIndex: 1 }, + { roundIndex: 4, matchIndex: 1 }, + ]); + }); +}); diff --git a/migration/contracts/src/server/services/calendar.test.ts b/migration/contracts/src/server/services/calendar.test.ts new file mode 100644 index 0000000..6985017 --- /dev/null +++ b/migration/contracts/src/server/services/calendar.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import type { Group, TeamSummary } from '../../shared/types/tournament.js'; +import { generateRoundRobinSchedule } from './calendar.js'; + +const team = (id: number, group: Group): TeamSummary => ({ + id, + number: id, + name: `Equipa ${id}`, + group, +}); + +function pairCounts(schedule: ReturnType): Map { + const counts = new Map(); + for (const game of schedule) { + const key = [game.teamA.id, game.teamB.id].sort((a, b) => a - b).join('-'); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; +} + +describe('generateRoundRobinSchedule', () => { + it('gera as três jornadas de um grupo par com quatro equipas', () => { + const group: Group = { id: 1, name: 'Grupo A', sortOrder: 0 }; + const teams = [1, 2, 3, 4].map((id) => team(id, group)); + + const schedule = generateRoundRobinSchedule(teams, 1); + + expect(schedule.map((game) => ({ + gameNumber: game.gameNumber, + roundNumber: game.roundNumber, + groupId: game.group.id, + teams: [game.teamA.id, game.teamB.id], + }))).toEqual([ + { gameNumber: 1, roundNumber: 1, groupId: 1, teams: [1, 4] }, + { gameNumber: 2, roundNumber: 1, groupId: 1, teams: [2, 3] }, + { gameNumber: 3, roundNumber: 2, groupId: 1, teams: [3, 1] }, + { gameNumber: 4, roundNumber: 2, groupId: 1, teams: [4, 2] }, + { gameNumber: 5, roundNumber: 3, groupId: 1, teams: [1, 2] }, + { gameNumber: 6, roundNumber: 3, groupId: 1, teams: [3, 4] }, + ]); + }); + + it('suporta um grupo ímpar através de uma folga por jornada', () => { + const group: Group = { id: 1, name: 'Grupo A', sortOrder: 0 }; + const teams = [1, 2, 3].map((id) => team(id, group)); + + const schedule = generateRoundRobinSchedule(teams, 1); + + expect(schedule.map((game) => [game.roundNumber, game.teamA.id, game.teamB.id])).toEqual([ + [1, 2, 3], + [2, 3, 1], + [3, 1, 2], + ]); + expect(schedule.map((game) => game.roundNumber)).toEqual([1, 2, 3]); + }); + + it('combina grupos ordenados nas mesmas jornadas sem cruzar nem repetir equipas', () => { + const group2: Group = { id: 2, name: 'Grupo 2', sortOrder: 0 }; + const group1: Group = { id: 1, name: 'Grupo 1', sortOrder: 0 }; + const group3: Group = { id: 3, name: 'Grupo 3', sortOrder: 1 }; + const teams = [ + team(21, group2), + team(11, group1), + team(31, group3), + team(22, group2), + team(12, group1), + team(32, group3), + team(23, group2), + team(13, group1), + team(14, group1), + ]; + + const schedule = generateRoundRobinSchedule(teams, 1); + + expect(schedule.map((game) => [game.roundNumber, game.group.id])).toEqual([ + [1, 1], [1, 1], [1, 2], [1, 3], + [2, 1], [2, 1], [2, 2], + [3, 1], [3, 1], [3, 2], + ]); + expect(schedule.every((game) => ( + game.teamA.group.id === game.group.id && game.teamB.group.id === game.group.id + ))).toBe(true); + + for (const roundNumber of [1, 2, 3]) { + const participants = schedule + .filter((game) => game.roundNumber === roundNumber) + .flatMap((game) => [game.teamA.id, game.teamB.id]); + expect(new Set(participants).size).toBe(participants.length); + } + }); + + it('anexa uma segunda volta que espelha exatamente a primeira', () => { + const group: Group = { id: 1, name: 'Grupo A', sortOrder: 0 }; + const teams = [1, 2, 3, 4].map((id) => team(id, group)); + const firstLeg = generateRoundRobinSchedule(teams, 1); + + const schedule = generateRoundRobinSchedule(teams, 2); + + expect(schedule.slice(0, firstLeg.length)).toEqual(firstLeg); + expect(schedule.slice(firstLeg.length)).toEqual(firstLeg.map((game, index) => ({ + gameNumber: firstLeg.length + index + 1, + roundNumber: game.roundNumber + 3, + group: game.group, + teamA: game.teamB, + teamB: game.teamA, + }))); + }); + + it('agenda todos os pares uma vez por volta', () => { + const group: Group = { id: 1, name: 'Grupo A', sortOrder: 0 }; + const teams = [1, 2, 3, 4].map((id) => team(id, group)); + const expectedPairs = ['1-2', '1-3', '1-4', '2-3', '2-4', '3-4']; + + expect(pairCounts(generateRoundRobinSchedule(teams, 1))).toEqual( + new Map(expectedPairs.map((pair) => [pair, 1])), + ); + expect(pairCounts(generateRoundRobinSchedule(teams, 2))).toEqual( + new Map(expectedPairs.map((pair) => [pair, 2])), + ); + }); + + it('não altera a lista nem a ordem recebidas', () => { + const groupA: Group = { id: 1, name: 'Grupo A', sortOrder: 1 }; + const groupB: Group = { id: 2, name: 'Grupo B', sortOrder: 0 }; + const teams = [team(1, groupA), team(4, groupB), team(3, groupA), team(2, groupB)]; + const snapshot = structuredClone(teams); + + generateRoundRobinSchedule(teams, 2); + + expect(teams).toEqual(snapshot); + expect(teams.map(({ id }) => id)).toEqual([1, 4, 3, 2]); + }); + + it('rejeita um número de voltas inválido mesmo quando o tipo é contornado', () => { + expect(() => generateRoundRobinSchedule([], 3 as 1 | 2)).toThrowError(RangeError); + expect(() => generateRoundRobinSchedule([], 3 as 1 | 2)).toThrow('1 ou 2'); + }); +}); diff --git a/migration/contracts/src/server/services/classification.test.ts b/migration/contracts/src/server/services/classification.test.ts new file mode 100644 index 0000000..6f54c35 --- /dev/null +++ b/migration/contracts/src/server/services/classification.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import type { Group, TeamSummary } from '../../shared/types/tournament.js'; +import { calculateClassification, type ClassificationMatch } from './classification.js'; + +const group: Group = { id: 1, name: 'Grupo A', sortOrder: 0 }; +const team = (id: number, number = id): TeamSummary => ({ id, number, name: `Equipa ${number}`, group }); +const teams = [team(1), team(2), team(3), team(4)]; +const match = (teamA: TeamSummary, teamB: TeamSummary, scoreA: number | null, scoreB: number | null): ClassificationMatch => ({ + teamA, + teamB, + scoreA, + scoreB, +}); + +describe('calculateClassification', () => { + it('mantém a classificação standard como modo predefinido', () => { + const matches = [ + match(teams[0], teams[1], 2, 0), + match(teams[2], teams[1], 4, 1), + ]; + + const standard = calculateClassification(teams.slice(0, 3), matches, 'standard'); + expect(calculateClassification(teams.slice(0, 3), matches)).toEqual(standard); + expect(standard.map((row) => ({ id: row.team.id, points: row.points }))).toEqual([ + { id: 3, points: 3 }, + { id: 1, points: 3 }, + { id: 2, points: 0 }, + ]); + }); + + it('calcula vitórias, empates, derrotas e ignora resultados incompletos', () => { + const table = calculateClassification(teams.slice(0, 3), [ + match(teams[0], teams[1], 3, 1), + match(teams[0], teams[2], 2, 2), + match(teams[1], teams[2], null, null), + ]); + expect(table[0]).toMatchObject({ team: teams[0], played: 2, wins: 1, draws: 1, losses: 0, goalsFor: 5, goalsAgainst: 3, goalDifference: 2, points: 4 }); + expect(table.find((row) => row.team.id === 2)).toMatchObject({ played: 1, losses: 1, points: 0 }); + expect(table.find((row) => row.team.id === 3)).toMatchObject({ played: 1, draws: 1, points: 1 }); + }); + + it('ordena por pontos, diferença de golos e golos marcados', () => { + const table = calculateClassification(teams.slice(0, 3), [ + match(teams[0], teams[1], 2, 0), + match(teams[2], teams[1], 4, 1), + ]); + expect(table.map((row) => row.team.id)).toEqual([3, 1, 2]); + }); + + it('usa confronto direto para equipas empatadas nos critérios principais', () => { + const table = calculateClassification(teams, [ + match(teams[0], teams[1], 1, 0), + match(teams[0], teams[2], 0, 1), + match(teams[1], teams[3], 1, 0), + ]); + const teamOnePosition = table.findIndex((row) => row.team.id === 1); + const teamTwoPosition = table.findIndex((row) => row.team.id === 2); + expect(teamOnePosition).toBeLessThan(teamTwoPosition); + }); + + it('usa o número da equipa como último critério determinístico', () => { + const reversed = [team(10, 9), team(11, 2)]; + expect(calculateClassification(reversed, []).map((row) => row.team.number)).toEqual([2, 9]); + }); + + describe('total-points', () => { + it('soma os resultados marcados por cada equipa nos pontos', () => { + const table = calculateClassification(teams.slice(0, 3), [ + match(teams[0], teams[1], 8, 5), + match(teams[0], teams[2], 3, 4), + match(teams[1], teams[2], 7, 6), + ], 'total-points'); + + expect(table.map((row) => ({ id: row.team.id, points: row.points }))).toEqual([ + { id: 2, points: 12 }, + { id: 1, points: 11 }, + { id: 3, points: 10 }, + ]); + expect(table.find((row) => row.team.id === 1)).toMatchObject({ + played: 2, + wins: 1, + draws: 0, + losses: 1, + goalsFor: 11, + goalsAgainst: 9, + goalDifference: 2, + }); + }); + + it('resolve totais empatados pelo confronto direto, não pela diferença global', () => { + const table = calculateClassification(teams, [ + match(teams[0], teams[1], 0, 1), + match(teams[0], teams[2], 5, 0), + match(teams[1], teams[3], 4, 10), + ], 'total-points'); + + expect(table.map((row) => row.team.id)).toEqual([4, 2, 1, 3]); + expect(table.find((row) => row.team.id === 1)).toMatchObject({ points: 5, goalDifference: 4 }); + expect(table.find((row) => row.team.id === 2)).toMatchObject({ points: 5, goalDifference: -5 }); + }); + + it('usa o número da equipa como fallback determinístico', () => { + const reversed = [team(10, 9), team(11, 2), team(12, 5)]; + + expect(calculateClassification(reversed, [], 'total-points').map((row) => row.team.number)) + .toEqual([2, 5, 9]); + }); + + it('ignora jogos incompletos sem acumular resultados ou estatísticas', () => { + const table = calculateClassification(teams.slice(0, 3), [ + match(teams[0], teams[1], 7, 3), + match(teams[0], teams[2], 9, null), + match(teams[1], teams[2], null, 8), + ], 'total-points'); + + expect(table.find((row) => row.team.id === 1)).toMatchObject({ played: 1, points: 7, goalsFor: 7 }); + expect(table.find((row) => row.team.id === 2)).toMatchObject({ played: 1, points: 3, goalsFor: 3 }); + expect(table.find((row) => row.team.id === 3)).toMatchObject({ played: 0, points: 0, goalsFor: 0 }); + }); + }); +}); diff --git a/migration/contracts/src/shared/finalSeeding.test.ts b/migration/contracts/src/shared/finalSeeding.test.ts new file mode 100644 index 0000000..106dc39 --- /dev/null +++ b/migration/contracts/src/shared/finalSeeding.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { GroupStanding, TeamSummary } from './types/tournament'; +import { buildGroupQualificationPreview, buildOverallQualificationPreview, standardBracketSeedOrder } from './finalSeeding'; + +function team(id: number, number: number, name: string, groupId: number, groupName: string): TeamSummary { + return { id, number, name, group: { id: groupId, name: groupName, sortOrder: groupId - 1 } }; +} + +function standing(groupId: number, groupName: string, teams: TeamSummary[]): GroupStanding { + return { + group: { id: groupId, name: groupName, sortOrder: groupId - 1 }, + rows: teams.map((entry, index) => ({ + position: index + 1, + team: entry, + played: 1, + wins: index === 0 ? 1 : 0, + draws: 0, + losses: index === 0 ? 0 : 1, + goalsFor: index === 0 ? 3 : 1, + goalsAgainst: index === 0 ? 1 : 3, + goalDifference: index === 0 ? 2 : -2, + points: index === 0 ? 3 : 0, + })), + }; +} + +describe('final seeding', () => { + it('uses standard bracket seed placement', () => { + expect(standardBracketSeedOrder(2)).toEqual([1, 2]); + expect(standardBracketSeedOrder(4)).toEqual([1, 4, 2, 3]); + expect(standardBracketSeedOrder(8)).toEqual([1, 8, 4, 5, 2, 7, 3, 6]); + }); + + it('crosses the top two teams from two groups in the semi-finals', () => { + const a1 = team(1, 1, 'A1', 1, 'Grupo A'); + const a2 = team(2, 2, 'A2', 1, 'Grupo A'); + const b1 = team(3, 3, 'B1', 2, 'Grupo B'); + const b2 = team(4, 4, 'B2', 2, 'Grupo B'); + + const preview = buildGroupQualificationPreview([ + standing(1, 'Grupo A', [a1, a2]), + standing(2, 'Grupo B', [b1, b2]), + ], 2, 4); + + expect(preview).toMatchObject({ mode: 'per-group', qualifiersPerGroup: 2, qualifierCount: 4 }); + expect(preview.qualifiers.map((qualifier) => qualifier.team.id)).toEqual([a1.id, b1.id, a2.id, b2.id]); + expect(preview.seeds).toEqual([ + { slotIndex: 1, teamId: a1.id }, + { slotIndex: 2, teamId: b2.id }, + { slotIndex: 3, teamId: b1.id }, + { slotIndex: 4, teamId: a2.id }, + ]); + }); + + it('leaves lower seeds empty when the bracket has extra slots', () => { + const a1 = team(1, 1, 'A1', 1, 'Grupo A'); + const b1 = team(2, 2, 'B1', 2, 'Grupo B'); + + const preview = buildGroupQualificationPreview([ + standing(1, 'Grupo A', [a1]), + standing(2, 'Grupo B', [b1]), + ], 1, 4); + + expect(preview.seeds).toEqual([ + { slotIndex: 1, teamId: a1.id }, + { slotIndex: 2, teamId: null }, + { slotIndex: 3, teamId: b1.id }, + { slotIndex: 4, teamId: null }, + ]); + }); + + it('can select the best teams across all groups', () => { + const a1 = team(1, 1, 'A1', 1, 'Grupo A'); + const a2 = team(2, 2, 'A2', 1, 'Grupo A'); + const b1 = team(3, 3, 'B1', 2, 'Grupo B'); + const b2 = team(4, 4, 'B2', 2, 'Grupo B'); + const groupA = standing(1, 'Grupo A', [a1, a2]); + const groupB = standing(2, 'Grupo B', [b1, b2]); + groupB.rows[0].points = 6; + groupB.rows[0].goalDifference = 4; + + const preview = buildOverallQualificationPreview([groupA, groupB], 2, 4, 'standard'); + + expect(preview).toMatchObject({ mode: 'overall', qualifiersPerGroup: null, qualifierCount: 2 }); + expect(preview.qualifiers.map((qualifier) => qualifier.team.id)).toEqual([b1.id, a1.id]); + expect(preview.seeds).toEqual([ + { slotIndex: 1, teamId: b1.id }, + { slotIndex: 2, teamId: null }, + { slotIndex: 3, teamId: a1.id }, + { slotIndex: 4, teamId: null }, + ]); + }); + +}); diff --git a/migration/phase3/compatibility.json b/migration/phase3/compatibility.json new file mode 100644 index 0000000..b6ed46b --- /dev/null +++ b/migration/phase3/compatibility.json @@ -0,0 +1,188 @@ +{ + "passed": 44, + "tests": [ + { + "name": "tournament manager HTTP API rejects unauthenticated administration and accepts only the configured login password", + "status": "passed" + }, + { + "name": "tournament manager HTTP API scopes the administration session cookie to the deployment path", + "status": "passed" + }, + { + "name": "tournament manager HTTP API publishes the admin-selected panel and zoom to the public display state", + "status": "passed" + }, + { + "name": "tournament manager HTTP API notifies connected displays when the selected panel changes", + "status": "passed" + }, + { + "name": "tournament manager HTTP API creates teams in Grupo A and rejects globally duplicated team numbers", + "status": "passed" + }, + { + "name": "tournament manager HTTP API assigns the next team number when creating a team without one", + "status": "passed" + }, + { + "name": "tournament manager HTTP API manages groups and prevents deleting nonempty or final groups", + "status": "passed" + }, + { + "name": "tournament manager HTTP API previews and saves the exact one-leg schedule for four teams", + "status": "passed" + }, + { + "name": "tournament manager HTTP API rejects generation when no group can produce a match", + "status": "passed" + }, + { + "name": "tournament manager HTTP API keeps a round out of the standings until an administrator enables it", + "status": "passed" + }, + { + "name": "tournament manager HTTP API ranks total game points and resolves equal totals by the direct match", + "status": "passed" + }, + { + "name": "tournament manager HTTP API generates only intra-group games with persisted rounds and aligned public classifications", + "status": "passed" + }, + { + "name": "tournament manager HTTP API previews a second leg that exactly mirrors the first with reversed venues", + "status": "passed" + }, + { + "name": "tournament manager HTTP API rejects tampered and stale calendar generation descriptors", + "status": "passed" + }, + { + "name": "tournament manager HTTP API requires confirmation to replace a scored calendar and clears scores when confirmed", + "status": "passed" + }, + { + "name": "tournament manager HTTP API randomizes existing team numbers and group assignments after confirmation", + "status": "passed" + }, + { + "name": "tournament manager HTTP API configures and advances a final stage that is exposed through public state", + "status": "passed" + }, + { + "name": "tournament manager HTTP API accepts the next-match display panel", + "status": "passed" + }, + { + "name": "tournament manager HTTP API previews automatic final-stage qualification from group standings", + "status": "passed" + }, + { + "name": "final seeding uses standard bracket seed placement", + "status": "passed" + }, + { + "name": "final seeding crosses the top two teams from two groups in the semi-finals", + "status": "passed" + }, + { + "name": "final seeding leaves lower seeds empty when the bracket has extra slots", + "status": "passed" + }, + { + "name": "final seeding can select the best teams across all groups", + "status": "passed" + }, + { + "name": "calculateBracket faz avan\u00e7ar vencedores entre v\u00e1rias rondas e determina o campe\u00e3o", + "status": "passed" + }, + { + "name": "calculateBracket n\u00e3o faz avan\u00e7ar um empate que a API deve rejeitar", + "status": "passed" + }, + { + "name": "calculateBracket faz avan\u00e7ar uma equipa automaticamente num bye e deixa jogos vazios por apurar", + "status": "passed" + }, + { + "name": "calculateBracket cria o jogo do 3.\u00ba lugar com os derrotados das meias-finais", + "status": "passed" + }, + { + "name": "calculateBracket identifica todos os resultados a invalidar ap\u00f3s mudar um jogo a montante", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule gera as tr\u00eas jornadas de um grupo par com quatro equipas", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule suporta um grupo \u00edmpar atrav\u00e9s de uma folga por jornada", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule combina grupos ordenados nas mesmas jornadas sem cruzar nem repetir equipas", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule anexa uma segunda volta que espelha exatamente a primeira", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule agenda todos os pares uma vez por volta", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule n\u00e3o altera a lista nem a ordem recebidas", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule rejeita um n\u00famero de voltas inv\u00e1lido mesmo quando o tipo \u00e9 contornado", + "status": "passed" + }, + { + "name": "calculateClassification mant\u00e9m a classifica\u00e7\u00e3o standard como modo predefinido", + "status": "passed" + }, + { + "name": "calculateClassification calcula vit\u00f3rias, empates, derrotas e ignora resultados incompletos", + "status": "passed" + }, + { + "name": "calculateClassification ordena por pontos, diferen\u00e7a de golos e golos marcados", + "status": "passed" + }, + { + "name": "calculateClassification usa confronto direto para equipas empatadas nos crit\u00e9rios principais", + "status": "passed" + }, + { + "name": "calculateClassification usa o n\u00famero da equipa como \u00faltimo crit\u00e9rio determin\u00edstico", + "status": "passed" + }, + { + "name": "calculateClassification total-points soma os resultados marcados por cada equipa nos pontos", + "status": "passed" + }, + { + "name": "calculateClassification total-points resolve totais empatados pelo confronto direto, n\u00e3o pela diferen\u00e7a global", + "status": "passed" + }, + { + "name": "calculateClassification total-points usa o n\u00famero da equipa como fallback determin\u00edstico", + "status": "passed" + }, + { + "name": "calculateClassification total-points ignora jogos incompletos sem acumular resultados ou estat\u00edsticas", + "status": "passed" + } + ], + "contract_sha256": { + "src/server/app.integration.test.ts": "a8d16cedbfbfcacc33f234c4fe60e09078dac117953a3254106d0a701865488c", + "src/server/services/calendar.test.ts": "3ed80358e56a2447242f0d5e8907ac558cde9983149ed618e63cfc530c189248", + "src/server/services/classification.test.ts": "83eddf6ee8b6f1779d0c5488cbf34ed3e6e4f4777172ad37cee42fc060d62e91", + "src/server/services/bracket.test.ts": "9a930d995b020751609bc70658e5a3514cbc0feb559fb51e0ed6dbd2d9a9dbc6", + "src/shared/finalSeeding.test.ts": "5e4a3ea66324d0b488a29109263495c0d5ee5c831a149e659b2e5dfd9b23a99b" + } +} diff --git a/migration/python_domain_adapter.mjs b/migration/python_domain_adapter.mjs new file mode 100644 index 0000000..82173fe --- /dev/null +++ b/migration/python_domain_adapter.mjs @@ -0,0 +1,19 @@ +import { spawnSync } from 'node:child_process'; + +function call(name, args) { + const result = spawnSync(process.env.TOURNAMENT_PYTHON, [process.env.TOURNAMENT_BRIDGE], { + input: JSON.stringify({ name, args }), encoding: 'utf8', env: process.env, + }); + if (result.status !== 0) throw new Error(result.stderr || 'Python bridge failed'); + const value = JSON.parse(result.stdout); + if (value.error) throw new RangeError(value.error); + return value.result; +} + +export const generateRoundRobinSchedule = (...args) => call('calendar', args); +export const calculateClassification = (...args) => call('classification', args); +export const calculateBracket = (...args) => call('bracket', args); +export const downstreamMatches = (...args) => call('downstream', args); +export const standardBracketSeedOrder = (...args) => call('seed-order', args); +export const buildGroupQualificationPreview = (...args) => call('group-qualification', args); +export const buildOverallQualificationPreview = (...args) => call('overall-qualification', args); diff --git a/migration/python_domain_bridge.py b/migration/python_domain_bridge.py new file mode 100644 index 0000000..7b04f28 --- /dev/null +++ b/migration/python_domain_bridge.py @@ -0,0 +1,62 @@ +"""Execute actual Python services for the legacy deterministic-service assertions.""" + +import json +import os +import sys + +sys.path.insert(0, os.environ["TOURNAMENT_BACKEND"]) + +from app.schemas.base import Schema +from app.schemas.state import GroupStanding +from app.schemas.teams import TeamSummary +from app.services.bracket import calculate_bracket, downstream_matches +from app.services.calendar import generate_calendar +from app.services.classification import calculate_classification +from app.services.seeding import group_qualification, overall_qualification, seed_order + + +class MatchScores(Schema): + team_a: TeamSummary + team_b: TeamSummary + score_a: int | None + score_b: int | None + + +def serialize(value): + if isinstance(value, Schema): + return value.model_dump(by_alias=True) + if isinstance(value, list): + return [serialize(item) for item in value] + return value + + +def execute(name, args): + if name == "calendar": + return generate_calendar([TeamSummary.model_validate(item) for item in args[0]], args[1]) + if name == "classification": + return calculate_classification([TeamSummary.model_validate(item) for item in args[0]], + [MatchScores.model_validate(item) for item in args[1]], + args[2] if len(args) > 2 else "standard") + if name == "bracket": + seeds = {item["slotIndex"]: TeamSummary.model_validate(item["team"]) if item["team"] else None for item in args[1]} + results = {(item["roundIndex"], item["matchIndex"]): (item["scoreA"], item["scoreB"]) for item in args[2]} + return calculate_bracket(args[0], seeds, results, args[3] if len(args) > 3 else False) + if name == "downstream": + return [{"roundIndex": index, "matchIndex": number} for index, number in downstream_matches(*args)] + if name == "seed-order": + return seed_order(args[0]) + standings = [GroupStanding.model_validate(item) for item in args[0]] + if name == "group-qualification": + return group_qualification(standings, args[1], args[2]) + if name == "overall-qualification": + return overall_qualification(standings, args[1], args[2], args[3]) + raise ValueError(f"Unknown service: {name}") + + +if __name__ == "__main__": + request = json.load(sys.stdin) + try: + response = {"result": serialize(execute(request["name"], request["args"]))} + except ValueError as error: + response = {"error": str(error)} + print(json.dumps(response, ensure_ascii=False)) diff --git a/migration/python_fastify_adapter.mjs b/migration/python_fastify_adapter.mjs new file mode 100644 index 0000000..2940be9 --- /dev/null +++ b/migration/python_fastify_adapter.mjs @@ -0,0 +1,73 @@ +// Migration-only transport adapter: runs the unchanged legacy HTTP assertions on FastAPI. +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import net from 'node:net'; +import { join } from 'node:path'; + +export const SESSION_COOKIE_NAME = 'tournament_admin_session'; + +export async function createApp(options) { + const temporary = mkdtempSync(join(process.env.TOURNAMENT_WORK, 'api-')); + const socket = net.createServer(); + await new Promise(resolve => socket.listen(0, '127.0.0.1', resolve)); + const port = socket.address().port; + await new Promise(resolve => socket.close(resolve)); + const processHandle = spawn(process.env.TOURNAMENT_PYTHON, [ + '-m', 'uvicorn', 'app.main:create_app', '--factory', '--host', '127.0.0.1', + '--port', String(port), '--no-proxy-headers', + ], { cwd: process.env.TOURNAMENT_BACKEND, env: { + ...process.env, ADMIN_PASSWORD: options.authentication.adminPassword, + SESSION_SECRET: options.authentication.sessionSecret, + DATABASE_PATH: join(temporary, 'tournament.sqlite'), APP_BASE_PATH: options.basePath, + TRUST_PROXY: 'false', PYTHONDONTWRITEBYTECODE: '1', + }, stdio: ['ignore', 'pipe', 'pipe'] }); + let logs = ''; + processHandle.stdout.on('data', chunk => { logs += chunk; }); + processHandle.stderr.on('data', chunk => { logs += chunk; }); + const base = `http://127.0.0.1:${port}`; + async function close() { + if (processHandle.exitCode === null) { + processHandle.kill('SIGTERM'); + const timer = setTimeout(() => processHandle.kill('SIGKILL'), 3000); + await new Promise(resolve => processHandle.once('exit', resolve)); + clearTimeout(timer); + } + rmSync(temporary, { recursive: true, force: true }); + } + try { + let ready = false; + for (let count = 0; count < 100; count++) { + try { + const response = await fetch(base + '/api/auth/session'); + if (response.ok) { ready = true; break; } + } catch { /* Wait for Uvicorn to bind its socket. */ } + if (processHandle.exitCode !== null) throw new Error(logs); + await new Promise(resolve => setTimeout(resolve, 50)); + } + if (!ready) throw new Error('FastAPI startup timed out: ' + logs); + } catch (error) { + await close(); + throw error; + } + return { + ready: async () => {}, listen: async () => {}, server: { address: () => ({ port }) }, close, + async inject(input) { + const headers = { ...input.headers }; + if (input.payload !== undefined) headers['content-type'] = 'application/json'; + const response = await fetch(base + input.url, { + method: input.method, headers, + body: input.payload === undefined ? undefined : JSON.stringify(input.payload), + }); + const body = await response.text(); + return { + statusCode: response.status, headers: Object.fromEntries(response.headers), body, + json: () => JSON.parse(body), + cookies: response.headers.getSetCookie().map(cookie => { + const [pair] = cookie.split(';'); + const separator = pair.indexOf('='); + return { name: pair.slice(0, separator), value: pair.slice(separator + 1) }; + }), + }; + }, + }; +} diff --git a/pyproject.toml b/pyproject.toml index 916e6eb..4312019 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,4 @@ [tool.uv.workspace] -members = ["apps/shifts/backend"] +members = ["apps/shifts/backend", "apps/tournament/backend"] -# Tournament joins the Python workspace when its FastAPI backend is created. # Shared Python infrastructure is introduced only after proven duplication. diff --git a/pyrightconfig.json b/pyrightconfig.json index 674a8db..d4793f2 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,5 +1,9 @@ { - "include": ["apps/shifts/backend/app", "apps/shifts/backend/tests"], + "include": ["apps/shifts/backend/app", "apps/shifts/backend/tests", "apps/tournament/backend/app", "apps/tournament/backend/tests"], + "executionEnvironments": [ + {"root": "apps/shifts/backend"}, + {"root": "apps/tournament/backend"} + ], "exclude": ["originals", ".migration", "**/node_modules", "**/__pycache__"], "venvPath": ".", "venv": ".venv", diff --git a/tools/check_images.py b/tools/check_images.py index 5ed81ce..1c61fb0 100644 --- a/tools/check_images.py +++ b/tools/check_images.py @@ -2,6 +2,7 @@ import json from html.parser import HTMLParser +from pathlib import Path import subprocess import time from urllib.request import urlopen @@ -22,13 +23,18 @@ class PageResources(HTMLParser): self.urls.append(attributes.get("href") or "") -def check(image: str, port: int, environment: dict[str, str], routes: list[str], proxy_prefix: str = "") -> None: +def check(image: str, port: int, environment: dict[str, str], routes: list[str], proxy_prefix: str = "", + legacy_fixture: Path | None = None, expected_state: dict | None = None) -> None: name = f"crr-migration-{uuid4().hex[:12]}" command = ["docker", "run", "--rm", "--detach", "--name", name, "--tmpfs", "/data:mode=1777", "--publish", f"127.0.0.1::{port}"] for key, value in environment.items(): command.extend(["--env", f"{key}={value}"]) + if legacy_fixture is not None: + command.extend(["--mount", f"type=bind,source={legacy_fixture},target=/legacy.sqlite,readonly"]) command.append(image) + if legacy_fixture is not None: + command.extend(["sh", "-c", "cp /legacy.sqlite /data/tournament.sqlite && exec uvicorn app.main:create_app --factory --host 0.0.0.0 --port 8080 --no-proxy-headers"]) subprocess.run(command, check=True, capture_output=True, text=True) try: binding = subprocess.check_output(["docker", "port", name, str(port)], text=True).strip() @@ -48,7 +54,10 @@ def check(image: str, port: int, environment: dict[str, str], routes: list[str], assert response.status == 200, route body = response.read() if route == "/api/public/state": - assert json.loads(body)["tournament"]["name"] == "Torneio" + if expected_state is not None: + assert json.loads(body) == expected_state + else: + assert json.loads(body)["tournament"]["name"] == "Torneio" elif route in {"/results", "/login"}: assert b"