diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..771540f --- /dev/null +++ b/.clang-format @@ -0,0 +1,48 @@ +# Modulo C++ formatting rules. +# Applied by scripts/format.sh using clang-format from /opt/homebrew/opt/llvm/bin. +--- +Language: Cpp +BasedOnStyle: LLVM +Standard: Latest + +# Layout +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ColumnLimit: 120 +AccessModifierOffset: -4 +IndentPPDirectives: BeforeHash +NamespaceIndentation: None +FixNamespaceComments: true +InsertNewlineAtEOF: true + +# Pointers & references bind to the type: `int* p`, `const QString& s`. +PointerAlignment: Left +ReferenceAlignment: Left +DerivePointerAlignment: false + +# Declarations +BreakTemplateDeclarations: Yes +AllowShortFunctionsOnASingleLine: InlineOnly +AllowShortLambdasOnASingleLine: All +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +SeparateDefinitionBlocks: Always + +# Include ordering: main header first (clang-format default), then local "quoted", +# then project , then Qt, then third-party, then the C++ standard library. +SortIncludes: CaseSensitive +IncludeBlocks: Regroup +IncludeCategories: + - Regex: '^"' + Priority: 1 + - Regex: '^$' + Priority: 5 + - Regex: '.*' + Priority: 4 diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..cf7dd36 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,40 @@ +# Modulo static-analysis rules. +# Run via the `dev-tidy` CMake preset (clang-tidy from /opt/homebrew/opt/llvm/bin). +# +# Check families: bugprone, performance, modernize, readability. +# Suppressions (kept deliberately short): +# - bugprone-easily-swappable-parameters: too noisy for small DTO/ctor signatures. +# - modernize-use-trailing-return-type: we use classic return-type style. +# - modernize-use-nodiscard: project decision — no [[nodiscard]] on declarations. +# - readability-identifier-length: short names (id, tx, db) are idiomatic here. +# - readability-magic-numbers: config defaults and test literals would drown the signal. +--- +Checks: > + bugprone-*, + performance-*, + modernize-*, + readability-*, + -bugprone-easily-swappable-parameters, + -modernize-use-trailing-return-type, + -modernize-use-nodiscard, + -readability-identifier-length, + -readability-magic-numbers + +WarningsAsErrors: '' +HeaderFilterRegex: '.*/(include|src)/modulo/.*|.*/(libs|server|client)/.*\.h$' +FormatStyle: file + +CheckOptions: + # Naming: CamelCase types, camelBack functions/variables, snake_case namespaces, + # trailing underscore for private members, UPPER_CASE macros. + readability-identifier-naming.NamespaceCase: lower_case + readability-identifier-naming.ClassCase: CamelCase + readability-identifier-naming.StructCase: CamelCase + readability-identifier-naming.EnumCase: CamelCase + readability-identifier-naming.EnumConstantCase: CamelCase + readability-identifier-naming.FunctionCase: camelBack + readability-identifier-naming.VariableCase: camelBack + readability-identifier-naming.ParameterCase: camelBack + readability-identifier-naming.PrivateMemberSuffix: '_' + readability-identifier-naming.MacroDefinitionCase: UPPER_CASE + readability-function-cognitive-complexity.IgnoreMacros: true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a6369fb --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Modulo environment configuration. +# Copy to `.env` and adjust; `.env` is gitignored — NEVER commit real values. + +# PostgreSQL connection for the server and the migration runner. +# Port 5433 is deliberate: the dockerized Postgres 16 maps there to avoid the +# local Homebrew PostgreSQL 14 already listening on 5432. +MODULO_DB_URL=postgresql://modulo:modulo@localhost:5433/modulo_dev + +# Test database used by integration tests (`ctest --preset integration`). +# When unset, integration tests SKIP cleanly instead of failing. +MODULO_TEST_DB_URL=postgresql://modulo:modulo@localhost:5433/modulo_test + +# Port the REST API listens on (localhost only during development). +MODULO_HTTP_PORT=8080 + +# Root directory for server-managed files (uploaded documents live under +# documents/). Gitignored; becomes a mounted volume when containerized. +MODULO_DATA_DIR=./var/data diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..33efdbf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +# Continuous integration: configure + build (-Werror), formatting check, and the +# unit + UI test suites on a macOS runner (the project's primary platform). +# +# Integration tests need PostgreSQL, which macOS runners cannot provide via +# Docker; they are opt-in (MODULO_TEST_DB_URL) and therefore report as Skipped +# here. A Linux job with a Postgres service container arrives with the +# containerized backend (Increment 9). + +name: CI + +on: + push: + branches: [main, "increment-*"] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Build & test (macOS, Apple Silicon) + runs-on: macos-15 + timeout-minutes: 45 + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Install dependencies + # clang-format (small formula) instead of the full llvm keg; scripts/format.sh + # picks it up through CLANG_FORMAT. + run: brew install ninja qt libpq libpqxx libsodium clang-format + + - name: Show toolchain versions + run: | + cmake --version | head -1 + ninja --version + brew list --versions qt libpqxx libsodium clang-format + "$(brew --prefix clang-format)/bin/clang-format" --version + + - name: Configure + run: cmake --preset ci + + - name: Build + run: cmake --build --preset ci + + - name: Check formatting + env: + CLANG_FORMAT: /opt/homebrew/opt/clang-format/bin/clang-format + run: scripts/format.sh --check + + - name: Run tests (unit + ui; integration skips without a database) + run: ctest --preset ci diff --git a/.gitignore b/.gitignore index f417ad0..34d29a7 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,10 @@ compile_commands.json *creator.user* *_qmlcache.qrc + +# Modulo +build*/ +var/ +.env +.cache/ +.DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..f9b316f --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,37 @@ +# Modulo — personal investment tracker. +# +# Contains options, toolkit includes, dependency resolution and subdirectory wiring. +# All build logic is implemented as modulo_* functions in cmake/ (see cmake/ModuloTargets.cmake). + +cmake_minimum_required(VERSION 3.28) + +project( + Modulo + VERSION 0.1.0 + DESCRIPTION "Personal investment tracker" + LANGUAGES CXX) + +list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") + +# --- Options (set via CMakePresets.json) ------------------------------------- +option(MODULO_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) +option(MODULO_BUILD_TESTS "Build the test suites" ON) +option(MODULO_CLANG_TIDY "Run clang-tidy during compilation" OFF) +set(MODULO_SANITIZERS + "" + CACHE STRING "Comma-separated -fsanitize= list, e.g. 'address,undefined'") + +# --- Toolkit & dependencies -------------------------------------------------- +include(ModuloTargets) +include(Dependencies) +modulo_find_dependencies() + +qt_standard_project_setup(REQUIRES 6.8) + +enable_testing() + +# --- Project targets --------------------------------------------------------- +add_subdirectory(libs/core) +add_subdirectory(libs/api) +add_subdirectory(server) +add_subdirectory(client) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..44d33ca --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,137 @@ +{ + "version": 6, + "cmakeMinimumRequired": { + "major": 3, + "minor": 28, + "patch": 0 + }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_PREFIX_PATH": "/opt/homebrew/opt/qt", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", + "WrapOpenGL_AGL": "/Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk/System/Library/Frameworks/AGL.framework", + "PostgreSQL_ROOT": "/opt/homebrew/opt/libpq" + } + }, + { + "name": "dev", + "displayName": "Development (Debug, warnings-as-errors)", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "MODULO_WARNINGS_AS_ERRORS": "ON" + } + }, + { + "name": "dev-asan", + "displayName": "Development + address/UB sanitizers", + "inherits": "dev", + "cacheVariables": { + "MODULO_SANITIZERS": "address,undefined" + } + }, + { + "name": "dev-tidy", + "displayName": "Development + clang-tidy on every compile", + "inherits": "dev", + "cacheVariables": { + "MODULO_CLANG_TIDY": "ON" + } + }, + { + "name": "release", + "displayName": "Release (RelWithDebInfo)", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo" + } + }, + { + "name": "ci", + "displayName": "Continuous integration (GitHub Actions macOS runner)", + "inherits": "dev", + "cacheVariables": { + "WrapOpenGL_AGL": "" + } + } + ], + "buildPresets": [ + { + "name": "dev", + "configurePreset": "dev" + }, + { + "name": "dev-asan", + "configurePreset": "dev-asan" + }, + { + "name": "dev-tidy", + "configurePreset": "dev-tidy" + }, + { + "name": "release", + "configurePreset": "release" + }, + { + "name": "ci", + "configurePreset": "ci" + } + ], + "testPresets": [ + { + "name": "unit", + "configurePreset": "dev", + "filter": { + "include": { + "label": "^unit$" + } + }, + "output": { + "outputOnFailure": true + } + }, + { + "name": "integration", + "configurePreset": "dev", + "filter": { + "include": { + "label": "^integration$" + } + }, + "output": { + "outputOnFailure": true + } + }, + { + "name": "ui", + "configurePreset": "dev", + "filter": { + "include": { + "label": "^ui$" + } + }, + "output": { + "outputOnFailure": true + } + }, + { + "name": "all", + "configurePreset": "dev", + "output": { + "outputOnFailure": true + } + }, + { + "name": "ci", + "configurePreset": "ci", + "output": { + "outputOnFailure": true + } + } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a3e8614 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Angelo Barbu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index cf620ba..4fee2bd 100644 --- a/README.md +++ b/README.md @@ -1 +1,301 @@ -# Modulo \ No newline at end of file +# Modulo + +[![CI](https://github.com/angelobarbu/Modulo/actions/workflows/ci.yml/badge.svg)](https://github.com/angelobarbu/Modulo/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-00ffa3.svg)](LICENSE) + +A personal investment tracker for crypto and stock assets — transactions, bank↔exchange +transfers, aggregated holdings with dashboards, uploaded documents, and daily exchange-rate +updates. + +**Architecture:** client-server. A C++23 REST backend (Qt `QHttpServer`) owns PostgreSQL, +authentication sessions and business logic; a Qt 6 / QML desktop client for macOS consumes +the API. The backend is designed to be containerized later and future web/mobile clients +can target the same API. + +**Stack:** C++23 · Qt 6.8 · QML · PostgreSQL 16 · CMake ≥ 3.28 · libpqxx · libsodium · +Qt Test / Qt Quick Test — no source-level dependencies. + +**Status:** pre-release, under active development. Increment 1 (foundations: build system, +database, migrations, REST skeleton, client shell, test scaffolding) is wrapping up with +public-repo readiness (license, CI); next up is authentication & RBAC. See the [Roadmap](#roadmap) and the +[Implementation log](#implementation-log). + +The project maximizes Qt framework usage — Qt is used everywhere unless it is clearly +costly and an alternative is much more efficient: QJson wire format, `Q_GADGET` DTOs readable +from QML, `QString` + `.arg()` as the project-wide string idiom, Qt integer typedefs +(`quint16`, etc.) in Qt-facing code, `qInfo()`/`qCritical()` logging in applications +(`QLoggingCategory` planned with the auth increment), Qt Test for every test suite, and Qt +networking/HTTP/UI throughout. The C++23 standard library is used only where Qt has no equivalent +(`std::expected`-based `Result`, `std::filesystem`). The Qt-free zone is +`server/modules/db` + `modulo_migrate` (pure libpqxx; stdout is the CLI's interface), +keeping the future container's migration entrypoint minimal. + +> Developed incrementally, one reviewed step at a time. This README grows with each step — +> see [Repository layout](#repository-layout) for what exists today. + +**Contents:** [Architecture](#architecture) · [Prerequisites](#prerequisites) · +[Building](#building) · [Running the stack](#running-the-stack) · +[Development database](#development-database) · [Testing](#testing) · +[Code style](#code-style) · [Development workflow](#development-workflow) · +[Repository layout](#repository-layout) · [Roadmap](#roadmap) · +[Implementation log](#implementation-log) · [License](#license) + +## Architecture + +Diagrams (components, library dependency graph, runtime flows, test layout) live in +[`docs/high_level_design.md`](docs/high_level_design.md) — rendered natively by GitHub. +The key structural decisions: + +- **Per-module static libraries.** Every server-side concern (`config`, `db`, `http`, soon + `auth`, `transactions`, …) is its own static library under `server/modules//` with + public headers in `include/modulo/server//`, implementation in `src/`, and its own + `tests/`. Shared code lives in `libs/core` (foundations) and `libs/api` (DTOs used verbatim + by server and client, so both sides agree on the wire format). +- **Errors as values.** `core::Result` (`std::expected`) carries a stable + dotted error code (`config.invalid_port`, `http.bind_failed`, `api.invalid_field`) that + tests and clients match on; exceptions are reserved for genuinely exceptional paths. +- **One error envelope.** Every endpoint answers failures with + `{"error":{"code":"…","message":"…"}}` and the matching HTTP status. +- **Validated wire data.** DTOs are `Q_GADGET` structs with `toJson()` / `fromJson()`; parsing + goes through `api::json::require*`, which rejects missing or mistyped fields instead of + accepting QJson's silent defaults. +- **Loopback-only server.** The API binds to `127.0.0.1`; production exposure will go + through a reverse proxy when the backend is containerized. + +## Prerequisites + +One-time setup on macOS (Apple Silicon): + +```sh +brew install cmake ninja llvm libpqxx libsodium qt +``` + +- **Qt 6.8+** is expected at `/opt/homebrew/opt/qt` (the CMake presets bake this path in). +- **llvm** provides `clang-format`/`clang-tidy`; it is keg-only, so scripts and CMake + reference `/opt/homebrew/opt/llvm/bin` by absolute path. +- **libpqxx** pulls in the keg-only `libpq`; the presets point CMake at it + (`PostgreSQL_ROOT=/opt/homebrew/opt/libpq`) so the build never depends on a stray + local PostgreSQL installation. +- **Docker** (Docker Desktop or any `docker compose` v2) must be running for the + development database. +- The local Homebrew PostgreSQL (if any) can run in parallel - the dockerized database uses + port **5433** precisely to avoid clashing with a local server on 5432. + +Two macOS/Homebrew quirks are compensated for in the build (no action needed): + +- The newest macOS SDK no longer ships the legacy `AGL` framework, but Qt's OpenGL CMake + wrapper unconditionally links it - the `dev` presets pin `WrapOpenGL_AGL` to the stub in + the older SDK (Homebrew's Qt itself links AGL at runtime, so this adds nothing new). +- A second Qt (`qtbase`) shadows the shared Homebrew plugin path with version-incompatible + plugins; the build generates a `qt.conf` beside every executable pinning plugin/QML + resolution to the Qt actually linked. + +Copy the environment template and adjust if needed: + +```sh +cp .env.example .env +``` + +## Building + +The build is driven entirely by CMake presets: + +```sh +cmake --preset dev # configure (Debug, warnings-as-errors) +cmake --build --preset dev # build +``` + +| Configure preset | Purpose | +|---|---| +| `dev` | Debug build, `-Werror`, compile-commands export | +| `dev-asan` | `dev` + address & undefined-behavior sanitizers | +| `dev-tidy` | `dev` + clang-tidy on every compile | +| `release` | RelWithDebInfo | +| `ci` | `dev` without the local AGL SDK pin — used by GitHub Actions | + +Build directories are generated in `build//`. All dependencies are Homebrew binary +libraries — nothing is downloaded at configure time. + +All build logic lives in `modulo_*` functions under [`cmake/`](cmake/) — +`modulo_add_library`, `modulo_add_executable`, `modulo_add_qml_app`, `modulo_add_test`, +`modulo_add_qml_test` — so every `CMakeLists.txt` is a short declarative call. The toolkit +applies C++23, the warning set, sanitizer/clang-tidy hooks, version injection, and +`qt.conf` generation uniformly, and auto-discovers each target's `tests/` directory. + +## Running the stack + +```sh +scripts/db-up.sh # 1. database (the health endpoint does not need it yet) +./build/dev/server/app/modulo_server # 2. REST API on http://127.0.0.1:8080 +./build/dev/client/modulo_client # 3. desktop client (separate terminal) +``` + +The server exposes `GET /api/v1/health` → `{"status":"ok","version":"0.1.0"}`; any +unknown route returns the uniform error envelope +`{"error":{"code":"not_found","message":"..."}}` with the matching HTTP status. The +client window (placeholder) polls health every 3 s and shows a live +green/red status indicator. `MODULO_HTTP_PORT` and `MODULO_API_URL` override the +server port and the client's target. + +## Development database + +Postgres 16 runs in Docker with a persistent named volume: + +```sh +scripts/db-up.sh # start + wait until healthy +scripts/db-down.sh # stop (data preserved) +scripts/db-down.sh --wipe # stop AND delete all data (asks for confirmation) +``` + +| What | Value | +|---|---| +| Host/port | `localhost:5433` (bound to 127.0.0.1 only) | +| Databases | `modulo_dev` (development), `modulo_test` (integration tests) | +| Credentials | user `modulo`, password `modulo` (dev-only) | +| Volume | `modulo_pgdata` | + +`modulo_test` is created by [`docker/initdb/01_create_test_db.sql`](docker/initdb/01_create_test_db.sql) +on the first initialization of an empty volume. + +### Migrations + +Schema changes are plain SQL files in [`db/migrations/`](db/migrations/), named +`NNNN_name.sql` and applied in version order by the `modulo_migrate` binary +(module `modulo_server_db`, wrapped by a script): + +```sh +scripts/migrate.sh # applies pending migrations to $MODULO_DB_URL +``` + +The runner tracks state in a `schema_migrations` table (version, name, content +checksum, timestamp) and enforces these rules: + +- each migration runs inside **one transaction** — a failure rolls back cleanly; +- already-applied, unchanged files are **skipped** (re-running is a no-op); +- migration files are **append-only**: editing an applied file changes its + checksum and the runner refuses to continue; +- a stray non-migration file in the directory is an error (dotfiles are tolerated). + +The database URL resolves in order: existing `MODULO_DB_URL` in the environment → +`.env` at the repo root → the dev-database default. `modulo_migrate --help` shows +the underlying CLI (`--url`, `--dir`). + +## Testing + +Tests are registered with CTest under the labels `unit`, `integration`, and `ui`: + +```sh +ctest --preset unit # Qt Test, fast, no Docker needed +ctest --preset integration # opt-in: set MODULO_TEST_DB_URL (see .env.example) +ctest --preset ui # Qt Quick Test, runs offscreen automatically +ctest --preset all +``` + +All suites use **Qt Test** (C++) and **Qt Quick Test** (QML) — one QObject test class per +binary, data-driven rows via `_data()` slots: + +| Test binary | Label | What it covers | +|---|---|---| +| `modulo_core_tests` | unit | `version()` matches the CMake project version, semver shape | +| `modulo_api_health_dto_tests` | unit | `HealthResponse` JSON round-trip; `fromJson` rejecting missing/mistyped fields | +| `modulo_api_error_dto_tests` | unit | `ErrorResponse` envelope shape and round-trip; rejection of flat/incomplete envelopes | +| `modulo_api_json_tests` | unit | `api::json::require*` never falling back to QJson's silent defaults (missing, number, object, array, null) | +| `modulo_server_config_tests` | unit | defaults, every variable, empty-means-unset, port 0, malformed ports → `config.invalid_port` | +| `modulo_integration_tests` | integration | real `QHttpServer` on an OS-assigned port + real HTTP client: `/api/v1/health` body and version, 404 error envelope | +| `modulo_client_qml_tests` | ui | `QUICK_TEST_MAIN` runner over `client/tests/qml/tst_*.qml` (Qt Quick + Material smoke) | + +Conventions: every module's tests live in its own `tests/` directory (auto-discovered by +the CMake toolkit); cross-module integration tests live in `server/tests/integration/`; +shared fixtures are in `tests/support/include/modulo/testing/`. Integration tests are +**opt-in**: they start with `MODULO_REQUIRE_TEST_DATABASE()`, which `QSKIP`s without +`MODULO_TEST_DB_URL`, and CTest reports the binary as *Skipped* — so `ctest --preset all` +never needs Docker to pass. + +## Code style + +- [`.clang-format`](.clang-format) — LLVM base, 4-space indent, 120 columns, `int* p` + pointer style, include groups ordered local → `` → Qt → third-party → std. +- [`.clang-tidy`](.clang-tidy) — `bugprone-*`, `performance-*`, `modernize-*`, + `readability-*` plus naming rules (`CamelCase` types, `camelBack` functions/variables, + trailing-underscore private members). + +```sh +scripts/format.sh # format all sources in place +scripts/format.sh --check # verify only (CI mode) +``` + +## Development workflow + +Work is organized in **increments** (a coherent feature area) made of small **steps**: + +- one branch per step (`increment-N-step-M`), one pull request per step into the + increment branch, **squash-merged** so each step is exactly one commit; +- the increment branch merges into `main` with a merge commit, preserving the per-step + history, and is tagged `v0..0` (matching the CMake project version); +- every step ships with its README update (see the [Implementation log](#implementation-log)) + and must pass a clean `-Werror` build, `scripts/format.sh --check`, and `ctest --preset all`. + +**Continuous integration** ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs on +every push to `main`/`increment-*` and on pull requests: a macOS (Apple Silicon) runner +installs the Homebrew dependencies, configures with the `ci` preset (identical to `dev` +minus the machine-specific AGL pin), builds with `-Werror`, checks formatting, and runs +the unit and UI suites. Integration tests report as *Skipped* in CI until a Linux job with +a PostgreSQL service container arrives alongside the containerized backend. + +## Repository layout + +``` +cmake/ CMake toolkit: all build logic as modulo_* functions +db/migrations/ append-only SQL schema migrations (NNNN_name.sql) +docs/ high_level_design.md (Mermaid architecture diagrams) +docker/ docker-compose.yml (Postgres 16 on :5433) + one-time initdb scripts +libs/core/ modulo_core — foundations: version(), Result (std::expected + QString error codes) +libs/api/ modulo_api — Q_GADGET DTOs + validating QJson mappings shared by server and client +scripts/ db-up.sh, db-down.sh, migrate.sh, format.sh +tests/support/ shared test fixtures () for integration tests +server/ backend: per-module static libraries + executables (each module has its own tests/) + modules/config/ modulo_server_config — env-based process configuration + modules/db/ modulo_server_db — migration engine (connection pool arrives in Increment 2) + modules/http/ modulo_server_http — QHttpServer wrapper, routes, error envelope + app/ modulo_server — REST API server executable + migrate/ modulo_migrate — CLI migration runner +client/ modulo_client — QML desktop app (ApiClient + dark-theme shell) +CMakeLists.txt thin root: options, toolkit includes, dependency resolution +CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release) +.env.example environment template (DB URLs, HTTP port, data dir) +``` + +## Roadmap + +| Increment | Scope | +|---|---| +| 1 — Foundations (in progress, final step) | Build system, dockerized Postgres, migrations, REST skeleton with health endpoint, client shell, test scaffolding, public-repo readiness | +| 2 — Auth & RBAC | Users/roles/sessions schema, Argon2id password hashing (libsodium), opaque bearer tokens, `authed()` / `requireRole()` guards, login flow + dark theme system in the client | +| 3 — Transactions | BUY/SELL/SWAP records with server-side filtering & pagination; add/edit/delete dialog with price-per-unit ⇄ total-value derivation | +| 4 — Transfers | Bank ⇄ exchange IN/OUT transfers; shared bank-account / exchange reference data | +| 5 — Holdings & dashboards | Per-asset aggregation (amount, median buy/sell, net profit, portfolio share, value in USD/EUR) and the first Qt Charts dashboards | +| 6 — Exchange rates | Daily USD/EUR, crypto and stock prices (Frankfurter, CoinGecko, Twelve Data) with manual refresh | +| 7 — Documents | Upload, link and preview exchange/bank documents | +| 8 — Theming & UX | Full ultrasound.money-inspired design system; empty/loading/error states everywhere | +| 9 — Deployment | Containerized backend (multi-stage Linux image, compose production profile, TLS via reverse proxy) | + +## Implementation log + +| Increment / step | Delivered | +|---|---| +| 1.0 — Prerequisites | Toolchain verified: ninja, llvm 22, libpqxx 8, libsodium, Qt 6.8.2 (no QPSQL driver → libpqxx), Docker | +| 1.1 — Style & hygiene | `.clang-format`, `.clang-tidy`, `.env.example`, `.gitignore` extension, `scripts/format.sh` | +| 1.2 — CMake superstructure | Function-based `cmake/` toolkit, vendored CPM v0.42.0, thin root `CMakeLists.txt`, presets | +| 1.3 — Dev database | Dockerized Postgres 16 (`:5433`, named volume, healthcheck), initdb for `modulo_test`, `db-up`/`db-down` scripts | +| 1.4 — Migrations | `modulo_server_db` module (first server static lib) with transactional, checksum-verified migration engine; `modulo_migrate` CLI; `0001_init.sql`; `scripts/migrate.sh` | +| 1.5 — Stubs across the stack | `modulo_core` (version, `Result`), `modulo_api` (Health/Error DTOs), `config` + `http` server modules, `modulo_server` serving `/api/v1/health`, QML client with live status; toolkit grew `modulo_add_qml_app`, version injection, qt.conf generation, AGL workaround | +| 1.5b — Qt-wide uniformity | Decision: maximize Qt uniformity. DTOs became `Q_GADGET`s with validating QJson mappings (`api::json::require*` — no silent defaults); nlohmann-json dependency removed. `QString` project-wide (incl. `core::Error`/`version()`), `.arg()` over `std::format` in Qt code, `quint16` in Qt-facing types, `qInfo`/`qCritical` in apps; Qt-free zone narrowed to `modules/db` + `modulo_migrate` | +| 1.5c — Cleanup | Further code & comments cleanup; revisioned documentation | +| 1.6 — Test scaffolding | One passing suite per layer: core, api (DTO + `require*` rejection paths), config, in-process HTTP integration (opt-in via `MODULO_TEST_DB_URL`, Skipped otherwise), QML smoke (offscreen); toolkit auto-discovers `tests/` dirs | +| 1.6b — Qt Test everywhere | Decision: Qt Test replaces Catch2 (one framework for C++ and QML); Catch2 + CPM removed — the project now has zero source-level dependencies | +| 1.7 — Docs finalization | README restructured for a public audience (status, contents, architecture, workflow, roadmap); HLD gained the test-architecture view; local working agreement (CLAUDE.md) refreshed | +| 1.8 — Public-repo readiness | MIT `LICENSE`; GitHub Actions CI (macOS runner: brew deps, `ci` preset, `-Werror` build, format check, unit + ui tests); README badges + License section; repository made public and tagged `v0.1.0` | + +## License + +Modulo is released under the [MIT License](LICENSE). diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt new file mode 100644 index 0000000..2c01b8d --- /dev/null +++ b/client/CMakeLists.txt @@ -0,0 +1,8 @@ +# modulo_client — the QML desktop application. + +modulo_add_qml_app( + modulo_client + URI Modulo + SOURCES include/modulo/client/api_client.h src/api_client.cpp src/main.cpp + QML_FILES qml/Main.qml + DEPS modulo_api Qt6::Quick Qt6::QuickControls2) diff --git a/client/include/modulo/client/api_client.h b/client/include/modulo/client/api_client.h new file mode 100644 index 0000000..04660f0 --- /dev/null +++ b/client/include/modulo/client/api_client.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include + +namespace modulo::client { + +/// Minimal client for the Modulo REST API exposed to QML as `ApiClient`. +/// The API base URL comes from the MODULO_API_URL environment variable. +/// Default: http://127.0.0.1:8080 +class ApiClient : public QObject { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(bool serverReachable READ serverReachable NOTIFY healthChanged) + Q_PROPERTY(QString serverStatus READ serverStatus NOTIFY healthChanged) + +public: + explicit ApiClient(QObject* parent = nullptr); + + bool serverReachable() const { return serverReachable_; } + + QString serverStatus() const { return serverStatus_; } + + /// GET /api/v1/health; the outcome lands in the properties above. + Q_INVOKABLE void checkHealth(); + +signals: + void healthChanged(); + +private: + QNetworkAccessManager network_; + QUrl baseUrl_; + bool serverReachable_ = false; + QString serverStatus_; +}; + +} // namespace modulo::client diff --git a/client/qml/Main.qml b/client/qml/Main.qml new file mode 100644 index 0000000..6f34bb8 --- /dev/null +++ b/client/qml/Main.qml @@ -0,0 +1,82 @@ +// Main application window. Placeholder shell for the ultrasound.money-inspired +// dark theme: near-black background, neon-green accent, muted secondary text. +// The real design system (Theme singleton, pages, navigation) arrives with +// the auth increment. + +import QtQuick +import QtQuick.Controls.Material +import QtQuick.Layouts +import Modulo + +ApplicationWindow { + id: window + + visible: true + width: 960 + height: 600 + minimumWidth: 480 + minimumHeight: 320 + title: qsTr("Modulo") + + Material.theme: Material.Dark + Material.accent: "#00ffa3" + color: "#10141b" + + ApiClient { + id: api + } + + Timer { + interval: 3000 + repeat: true + running: true + triggeredOnStart: true + onTriggered: api.checkHealth() + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 12 + + Label { + text: qsTr("Modulo") + font.pixelSize: 48 + font.weight: Font.DemiBold + color: "#e6edf3" + Layout.alignment: Qt.AlignHCenter + } + + Label { + text: qsTr("personal investment tracker") + font.pixelSize: 16 + color: "#8b949e" + Layout.alignment: Qt.AlignHCenter + } + + RowLayout { + spacing: 8 + Layout.alignment: Qt.AlignHCenter + + Rectangle { + id: statusDot + width: 10 + height: 10 + radius: width / 2 + color: api.serverReachable ? "#00ffa3" : "#f85149" + + SequentialAnimation on opacity { + running: api.serverReachable + loops: Animation.Infinite + NumberAnimation { from: 1.0; to: 0.35; duration: 900 } + NumberAnimation { from: 0.35; to: 1.0; duration: 900 } + } + } + + Label { + text: api.serverStatus + font.pixelSize: 14 + color: "#8b949e" + } + } + } +} diff --git a/client/src/api_client.cpp b/client/src/api_client.cpp new file mode 100644 index 0000000..8cff777 --- /dev/null +++ b/client/src/api_client.cpp @@ -0,0 +1,52 @@ +#include +#include + +#include +#include + +namespace modulo::client { + +namespace { + +QUrl defaultBaseUrl() { + return QUrl{qEnvironmentVariable("MODULO_API_URL", QStringLiteral("http://127.0.0.1:8080"))}; +} + +} // namespace + +ApiClient::ApiClient(QObject* parent) : QObject{parent}, baseUrl_{defaultBaseUrl()}, serverStatus_{tr("connecting…")} { +} + +void ApiClient::checkHealth() { + const QNetworkRequest request{baseUrl_.resolved(QUrl{QStringLiteral("/api/v1/health")})}; + auto* reply = network_.get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply] { + reply->deleteLater(); + + bool reachable = false; + QString status; + if (reply->error() == QNetworkReply::NoError) { + const auto document = QJsonDocument::fromJson(reply->readAll()); + const auto health = document.isObject() + ? api::HealthResponse::fromJson(document.object()) + : core::makeError(QStringLiteral("api.invalid_field"), + QStringLiteral("response body is not a JSON object")); + if (health && health->status == QStringLiteral("ok")) { + reachable = true; + status = tr("server %1 (v%2)").arg(health->status, health->version); + } else { + status = tr("invalid response from server"); + } + } else { + status = tr("server unreachable"); + } + + if (reachable != serverReachable_ || status != serverStatus_) { + serverReachable_ = reachable; + serverStatus_ = status; + emit healthChanged(); + } + }); +} + +} // namespace modulo::client diff --git a/client/src/main.cpp b/client/src/main.cpp new file mode 100644 index 0000000..5ab63e1 --- /dev/null +++ b/client/src/main.cpp @@ -0,0 +1,24 @@ +// modulo_client — Modulo QML desktop application. + +#include +#include +#include + +#include + +int main(int argc, char* argv[]) { + QGuiApplication app{argc, argv}; + QGuiApplication::setApplicationName(QStringLiteral("Modulo")); + QGuiApplication::setOrganizationName(QStringLiteral("Modulo")); + + // Material is the base Controls style; the Modulo dark theme layers on top. + QQuickStyle::setStyle(QStringLiteral("Material")); + + QQmlApplicationEngine engine; + QObject::connect( + &engine, &QQmlApplicationEngine::objectCreationFailed, &app, [] { QCoreApplication::exit(EXIT_FAILURE); }, + Qt::QueuedConnection); + engine.loadFromModule("Modulo", "Main"); + + return app.exec(); +} diff --git a/client/tests/CMakeLists.txt b/client/tests/CMakeLists.txt new file mode 100644 index 0000000..d52561b --- /dev/null +++ b/client/tests/CMakeLists.txt @@ -0,0 +1,7 @@ +# QML / Qt Quick tests: every tst_*.qml file in ./qml runs under the QUICK_TEST_MAIN +# runner, offscreen (no display needed). + +modulo_add_qml_test( + modulo_client_qml_tests + QML_DIR ${CMAKE_CURRENT_SOURCE_DIR}/qml + SOURCES qml/main.cpp) diff --git a/client/tests/qml/main.cpp b/client/tests/qml/main.cpp new file mode 100644 index 0000000..260592a --- /dev/null +++ b/client/tests/qml/main.cpp @@ -0,0 +1,6 @@ +// Qt Quick Test runner: executes every tst_*.qml in QUICK_TEST_SOURCE_DIR +// (injected by modulo_add_qml_test). + +#include + +QUICK_TEST_MAIN(modulo_client_qml) diff --git a/client/tests/qml/tst_smoke.qml b/client/tests/qml/tst_smoke.qml new file mode 100644 index 0000000..cf7f6ee --- /dev/null +++ b/client/tests/qml/tst_smoke.qml @@ -0,0 +1,38 @@ +// Smoke test: proves the QML test runner, the Qt Quick runtime, and the +// Material controls style are all available offscreen. Component-level +// tests (ApiClient, pages) arrive once the client's QML module is split into +// an importable library (auth increment). + +import QtQuick +import QtQuick.Controls.Material +import QtTest + +TestCase { + id: testCase + name: "Smoke" + + function test_qtquick_items_instantiate() { + const item = createTemporaryQmlObject("import QtQuick; Item { width: 42; height: 7 }", testCase) + verify(item) + compare(item.width, 42) + compare(item.height, 7) + } + + function test_material_controls_available() { + const button = createTemporaryQmlObject( + "import QtQuick.Controls.Material; Button { text: 'probe'; Material.theme: Material.Dark }", + testCase) + verify(button) + compare(button.text, "probe") + compare(button.Material.theme, Material.Dark) + } + + function test_dark_theme_palette_constants() { + // The placeholder palette used by Main.qml; guards against typos when + // it moves into the Theme singleton. + const background = Qt.color("#10141b") + const accent = Qt.color("#00ffa3") + verify(background.r < 0.1 && background.g < 0.1 && background.b < 0.15, "background is near-black") + verify(accent.g > 0.9 && accent.r < 0.1, "accent is neon green") + } +} diff --git a/cmake/CompilerWarnings.cmake b/cmake/CompilerWarnings.cmake new file mode 100644 index 0000000..8821be5 --- /dev/null +++ b/cmake/CompilerWarnings.cmake @@ -0,0 +1,29 @@ +# CompilerWarnings.cmake — project-wide warning configuration. +# +# Defines the `modulo_warnings` INTERFACE target carrying +# the warning flags shared by every first-party target +# and `modulo_enable_warnings() to attach them. +# +# The flag set is controlled by the MODULO_WARNINGS_AS_ERRORS option +# (declared in the root CMakeLists.txt enabled by the `dev` preset). + +include_guard(GLOBAL) + +add_library(modulo_warnings INTERFACE) + +target_compile_options( + modulo_warnings + INTERFACE -Wall + -Wextra + -Wpedantic + -Wconversion + -Wshadow) + +if(MODULO_WARNINGS_AS_ERRORS) + target_compile_options(modulo_warnings INTERFACE -Werror) +endif() + +# Attach the shared warning flags to a first-party target. +function(modulo_enable_warnings target) + target_link_libraries(${target} PRIVATE modulo_warnings) +endfunction() diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake new file mode 100644 index 0000000..cb51bb6 --- /dev/null +++ b/cmake/Dependencies.cmake @@ -0,0 +1,42 @@ +# Dependencies.cmake — Third-party dependencies. +# +# `modulo_find_dependencies()` resolves every external dependency in one +# place. All of them are Homebrew binary libraries: Qt 6.8+, libpqxx, libsodium. +# There are no source-level dependencies (testing uses Qt Test). +# +# A macro so find_package results land in the caller's +# directory scope. Called from the root CMakeLists.txt. + +include_guard(GLOBAL) + +macro(modulo_find_dependencies) + # Qt path comes from CMAKE_PREFIX_PATH (set by the presets: /opt/homebrew/opt/qt). + find_package( + Qt6 6.8 REQUIRED + COMPONENTS Core + Network + HttpServer + Qml + Quick + QuickControls2 + Test + QuickTest) + + # libpqxx ships CMake package config (target: libpqxx::pqxx). + find_package(libpqxx REQUIRED) + + # libsodium ships no CMake config, only pkg-config; locate it directly so + # the build has no pkg-config dependency (works identically in Docker later). + if(NOT TARGET sodium::sodium) + find_path(MODULO_SODIUM_INCLUDE_DIR sodium.h) + find_library(MODULO_SODIUM_LIBRARY sodium) + if(NOT MODULO_SODIUM_INCLUDE_DIR OR NOT MODULO_SODIUM_LIBRARY) + message(FATAL_ERROR "libsodium not found (brew install libsodium)") + endif() + add_library(sodium::sodium UNKNOWN IMPORTED) + set_target_properties( + sodium::sodium + PROPERTIES IMPORTED_LOCATION "${MODULO_SODIUM_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${MODULO_SODIUM_INCLUDE_DIR}") + endif() +endmacro() diff --git a/cmake/ModuloTargets.cmake b/cmake/ModuloTargets.cmake new file mode 100644 index 0000000..5879b02 --- /dev/null +++ b/cmake/ModuloTargets.cmake @@ -0,0 +1,230 @@ +# ModuloTargets.cmake — declarative target creation for first-party code. +# +# Every CMakeLists.txt in the repo stays a short, generic call into one of +# these functions; all shared logic (C++23, include/src layout, warnings, +# sanitizers, clang-tidy, CTest registration) are included here. +# +# modulo_add_library( SOURCES ... [PUBLIC_DEPS ...] [PRIVATE_DEPS ...]) +# Static library following the module convention: public headers in +# ./include (as ), implementation in ./src. +# +# modulo_add_executable( SOURCES ... [DEPS ...]) +# Application or tool binary. +# +# modulo_add_test( LABEL unit|integration SOURCES ... [DEPS ...]) +# Qt Test binary (one QObject test class, QTEST_GUILESS_MAIN), registered +# with CTest under the given label (labels drive the `unit` / +# `integration` / `all` test presets). +# +# modulo_add_qml_test( QML_DIR SOURCES ... [DEPS ...]) +# Qt Quick Test binary running the tst_*.qml files in QML_DIR, +# registered with CTest under the `ui` label. +# +# Test functions are no-ops when MODULO_BUILD_TESTS is OFF. + +include_guard(GLOBAL) + +include(CompilerWarnings) +include(Sanitizers) +include(StaticAnalysis) + +# Settings common to every first-party target (never applied to third-party code). +function(_modulo_apply_common_settings target) + target_compile_features(${target} PUBLIC cxx_std_23) + set_target_properties(${target} PROPERTIES CXX_EXTENSIONS OFF) + # Single source of truth for the project version: the root project() call. + target_compile_definitions(${target} PRIVATE MODULO_VERSION="${PROJECT_VERSION}") + modulo_enable_warnings(${target}) + modulo_enable_sanitizers(${target}) + modulo_enable_clang_tidy(${target}) +endfunction() + +# Write a qt.conf beside an executable, pinning Qt's plugin/QML resolution to +# the Qt installation we actually link against. Without this, Homebrew's keg-only +# qt resolves plugins via the brew prefix root (/opt/homebrew/share/qt), which a +# different Qt formula (e.g. a newer qtbase) can shadow — the app then tries to +# load version-incompatible plugins and refuses to start. +function(_modulo_write_qt_conf target) + if(NOT TARGET Qt6::Core) + return() + endif() + + # Executables land in the current binary dir; one qt.conf per directory + # serves every binary in it (generating the same file twice is an error). + get_property(_modulo_qt_conf_written DIRECTORY PROPERTY MODULO_QT_CONF_WRITTEN) + if(_modulo_qt_conf_written) + return() + endif() + set_property(DIRECTORY PROPERTY MODULO_QT_CONF_WRITTEN TRUE) + + get_filename_component(_modulo_qt_root "${Qt6_DIR}/../../.." ABSOLUTE) + if(EXISTS "${_modulo_qt_root}/share/qt/plugins") + set(_modulo_qt_prefix "${_modulo_qt_root}/share/qt") # Homebrew layout + else() + set(_modulo_qt_prefix "${_modulo_qt_root}") # official-installer layout + endif() + + file( + GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/qt.conf" + CONTENT "[Paths]\nPrefix = ${_modulo_qt_prefix}\n") +endfunction() + +# Module convention: a target's tests live in ./tests and are picked up +# automatically when MODULO_BUILD_TESTS is ON — no per-module wiring needed. +function(_modulo_add_tests_subdirectory) + if(MODULO_BUILD_TESTS AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt") + add_subdirectory(tests) + endif() +endfunction() + +function(modulo_add_library name) + cmake_parse_arguments(PARSE_ARGV 1 ARG "" "" "SOURCES;PUBLIC_DEPS;PRIVATE_DEPS") + + if(NOT ARG_SOURCES) + message(FATAL_ERROR "modulo_add_library(${name}): SOURCES is required") + endif() + + add_library(${name} STATIC ${ARG_SOURCES}) + + # Module convention: consumers include from ./include; + # implementation files may include internals from ./src. + target_include_directories( + ${name} + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + + if(ARG_PUBLIC_DEPS) + target_link_libraries(${name} PUBLIC ${ARG_PUBLIC_DEPS}) + endif() + if(ARG_PRIVATE_DEPS) + target_link_libraries(${name} PRIVATE ${ARG_PRIVATE_DEPS}) + endif() + + _modulo_apply_common_settings(${name}) + _modulo_add_tests_subdirectory() +endfunction() + +function(modulo_add_executable name) + cmake_parse_arguments(PARSE_ARGV 1 ARG "" "" "SOURCES;DEPS") + + if(NOT ARG_SOURCES) + message(FATAL_ERROR "modulo_add_executable(${name}): SOURCES is required") + endif() + + add_executable(${name} ${ARG_SOURCES}) + + if(ARG_DEPS) + target_link_libraries(${name} PRIVATE ${ARG_DEPS}) + endif() + + _modulo_apply_common_settings(${name}) + _modulo_write_qt_conf(${name}) +endfunction() + +function(modulo_add_qml_app name) + cmake_parse_arguments(PARSE_ARGV 1 ARG "" "URI" "SOURCES;QML_FILES;DEPS") + + if(NOT ARG_URI) + message(FATAL_ERROR "modulo_add_qml_app(${name}): URI is required") + endif() + if(NOT ARG_SOURCES) + message(FATAL_ERROR "modulo_add_qml_app(${name}): SOURCES is required") + endif() + + qt_add_executable(${name} ${ARG_SOURCES}) + qt_add_qml_module( + ${name} + URI ${ARG_URI} + VERSION 1.0 + QML_FILES ${ARG_QML_FILES}) + + # Apps follow the same include/src split as libraries, but their headers + # are private — nobody links against an application. + if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/include) + target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) + endif() + + # qmltyperegistrar's generated registration file includes each QML-exposed + # header by BASENAME only (guarded by __has_include, so a miss is silent + # and surfaces as "undeclared identifier" instead). Make every listed + # header's own directory an include dir so those basename includes resolve. + foreach(source IN LISTS ARG_SOURCES) + if(source MATCHES "\\.h$") + get_filename_component(header_dir "${CMAKE_CURRENT_SOURCE_DIR}/${source}" DIRECTORY) + target_include_directories(${name} PRIVATE "${header_dir}") + endif() + endforeach() + + if(ARG_DEPS) + target_link_libraries(${name} PRIVATE ${ARG_DEPS}) + endif() + + _modulo_apply_common_settings(${name}) + _modulo_write_qt_conf(${name}) + _modulo_add_tests_subdirectory() +endfunction() + +function(modulo_add_test name) + cmake_parse_arguments(PARSE_ARGV 1 ARG "" "LABEL" "SOURCES;DEPS") + + if(NOT MODULO_BUILD_TESTS) + return() + endif() + + if(NOT ARG_LABEL MATCHES "^(unit|integration)$") + message(FATAL_ERROR "modulo_add_test(${name}): LABEL must be 'unit' or 'integration'") + endif() + if(NOT ARG_SOURCES) + message(FATAL_ERROR "modulo_add_test(${name}): SOURCES is required") + endif() + + # One Qt Test class per binary (QTEST_GUILESS_MAIN in the single source file). + add_executable(${name} ${ARG_SOURCES}) + target_link_libraries(${name} PRIVATE Qt6::Test) + if(ARG_DEPS) + target_link_libraries(${name} PRIVATE ${ARG_DEPS}) + endif() + + # Shared test-support headers (): integration fixtures etc. + target_include_directories(${name} PRIVATE "${CMAKE_SOURCE_DIR}/tests/support/include") + + _modulo_apply_common_settings(${name}) + _modulo_write_qt_conf(${name}) + + add_test(NAME ${name} COMMAND ${name}) + # QSKIP() prints "SKIP : ..." and exits 0; make CTest report the binary as + # skipped (e.g. integration tests without a database), not passed. + set_tests_properties(${name} PROPERTIES LABELS ${ARG_LABEL} SKIP_REGULAR_EXPRESSION "SKIP : ") +endfunction() + +function(modulo_add_qml_test name) + cmake_parse_arguments(PARSE_ARGV 1 ARG "" "QML_DIR" "SOURCES;DEPS") + + if(NOT MODULO_BUILD_TESTS) + return() + endif() + + if(NOT ARG_QML_DIR) + message(FATAL_ERROR "modulo_add_qml_test(${name}): QML_DIR is required") + endif() + if(NOT ARG_SOURCES) + message(FATAL_ERROR "modulo_add_qml_test(${name}): SOURCES is required") + endif() + + add_executable(${name} ${ARG_SOURCES}) + target_link_libraries(${name} PRIVATE Qt6::QuickTest Qt6::Qml) + if(ARG_DEPS) + target_link_libraries(${name} PRIVATE ${ARG_DEPS}) + endif() + + # QUICK_TEST_SOURCE_DIR points the QUICK_TEST_MAIN runner at the tst_*.qml files. + target_compile_definitions(${name} PRIVATE QUICK_TEST_SOURCE_DIR="${ARG_QML_DIR}") + + _modulo_apply_common_settings(${name}) + _modulo_write_qt_conf(${name}) + + add_test(NAME ${name} COMMAND ${name}) + # QML tests need a QPA platform but no display: run them offscreen. + set_tests_properties(${name} PROPERTIES LABELS ui ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +endfunction() diff --git a/cmake/Sanitizers.cmake b/cmake/Sanitizers.cmake new file mode 100644 index 0000000..6aa2d45 --- /dev/null +++ b/cmake/Sanitizers.cmake @@ -0,0 +1,20 @@ +# Sanitizers.cmake — runtime sanitizer instrumentation. +# +# Defines `modulo_enable_sanitizers()`, which honors the +# MODULO_SANITIZERS cache variable: a comma-separated -fsanitize= value such +# as "address,undefined" (as set by the `dev-asan` preset). When the variable +# is empty (the default) this function is a no-op, so plain builds carry no +# instrumentation cost. + +include_guard(GLOBAL) + +# Instrument a first-party target with the sanitizers named in MODULO_SANITIZERS. +function(modulo_enable_sanitizers target) + if(NOT MODULO_SANITIZERS) + return() + endif() + + # -fno-omit-frame-pointer keeps sanitizer stack traces readable. + target_compile_options(${target} PRIVATE -fsanitize=${MODULO_SANITIZERS} -fno-omit-frame-pointer) + target_link_options(${target} PRIVATE -fsanitize=${MODULO_SANITIZERS}) +endfunction() diff --git a/cmake/StaticAnalysis.cmake b/cmake/StaticAnalysis.cmake new file mode 100644 index 0000000..83642e1 --- /dev/null +++ b/cmake/StaticAnalysis.cmake @@ -0,0 +1,29 @@ +# StaticAnalysis.cmake — clang-tidy integration. +# +# Defines `modulo_enable_clang_tidy()`, which honors the +# MODULO_CLANG_TIDY option (enabled by the `dev-tidy` preset). When on, +# every compile of the target also runs clang-tidy with the repo-root +# .clang-tidy configuration. +# +# Homebrew LLVM is keg-only, so the binary is referenced by absolute path; +# override with -DMODULO_CLANG_TIDY_EXE=... on other machines. + +include_guard(GLOBAL) + +set(MODULO_CLANG_TIDY_EXE + "/opt/homebrew/opt/llvm/bin/clang-tidy" + CACHE FILEPATH "clang-tidy executable used when MODULO_CLANG_TIDY is ON") + +# Run clang-tidy alongside compilation for a first-party target. +function(modulo_enable_clang_tidy target) + if(NOT MODULO_CLANG_TIDY) + return() + endif() + + if(NOT EXISTS "${MODULO_CLANG_TIDY_EXE}") + message(FATAL_ERROR "MODULO_CLANG_TIDY is ON but clang-tidy was not found at " + "'${MODULO_CLANG_TIDY_EXE}' (brew install llvm, or set MODULO_CLANG_TIDY_EXE)") + endif() + + set_target_properties(${target} PROPERTIES CXX_CLANG_TIDY "${MODULO_CLANG_TIDY_EXE}") +endfunction() diff --git a/db/migrations/0001_init.sql b/db/migrations/0001_init.sql new file mode 100644 index 0000000..195775b --- /dev/null +++ b/db/migrations/0001_init.sql @@ -0,0 +1,16 @@ +-- 0001_init: baseline migration. +-- +-- Establishes the meta table and exercises the migration pipeline end to end. +-- Real schema (auth, transactions, ...) arrives in later increments, one +-- append-only migration file each. Applied migration files must NEVER be +-- edited — the runner verifies checksums and refuses to continue if one +-- changes. + +CREATE TABLE meta ( + key text PRIMARY KEY, + value text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO meta (key, value) +VALUES ('schema_baseline', 'increment-1'); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..30ab29b --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,35 @@ +# Modulo development database. +# +# Postgres 16 with a named volume for persistence. The host port is 5433 +# (NOT the default 5432) because the local Homebrew PostgreSQL 14 already +# listens on 5432 on this machine. +# +# Managed via scripts/db-up.sh and scripts/db-down.sh. +# Data survives `down`; wipe it with scripts/db-down.sh --wipe. + +name: modulo + +services: + postgres: + image: postgres:16-alpine + container_name: modulo-postgres + restart: unless-stopped + ports: + # Bind to localhost only — never expose the dev database on the network. + - "127.0.0.1:5433:5432" + environment: + POSTGRES_USER: modulo + POSTGRES_PASSWORD: modulo + POSTGRES_DB: modulo_dev + volumes: + - modulo_pgdata:/var/lib/postgresql/data + # Runs *.sql once, on first initialization of an empty volume only. + - ./initdb:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U modulo -d modulo_dev"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + modulo_pgdata: diff --git a/docker/initdb/01_create_test_db.sql b/docker/initdb/01_create_test_db.sql new file mode 100644 index 0000000..9567e6d --- /dev/null +++ b/docker/initdb/01_create_test_db.sql @@ -0,0 +1,4 @@ +-- Create the integration-test database alongside the dev database. +-- Executed by the postgres image entrypoint on FIRST initialization of an +-- empty data volume only (wipe with scripts/db-down.sh --wipe to re-run). +CREATE DATABASE modulo_test OWNER modulo; diff --git a/docs/high_level_design.md b/docs/high_level_design.md new file mode 100644 index 0000000..d08e8d6 --- /dev/null +++ b/docs/high_level_design.md @@ -0,0 +1,172 @@ +# Modulo — High-Level Design + +## 1. Component & deployment view + +```mermaid +flowchart LR + subgraph desktop["macOS desktop"] + subgraph client["modulo_client (Qt 6.8 / QML)"] + qml["Main.qml +dark shell · status dot +polls every 3 s"] + apiclient["ApiClient (QObject) +QNetworkAccessManager +QML_ELEMENT"] + qml --> apiclient + end + + subgraph serverproc["modulo_server (C++23 / QCoreApplication)"] + http["http module +QHttpServer · routes +JSON error envelope"] + config["config module +env → Config +(MODULO_* vars)"] + http --> config + end + + subgraph migrate["modulo_migrate (CLI, Qt-free)"] + migrator["db module +Migrator · libpqxx +checksums · transactions"] + end + end + + subgraph docker["Docker"] + pg[("PostgreSQL 16 +127.0.0.1:5433 +modulo_dev · modulo_test +volume: modulo_pgdata")] + end + + sql["db/migrations/ +NNNN_name.sql +(append-only)"] + + apiclient -- "HTTP GET /api/v1/health +127.0.0.1:8080 (loopback only)" --> http + migrator -- "SQL over libpq" --> pg + sql --> migrator + http -. "libpqxx pool — Increment 2" .-> pg +``` + +## 2. Static library dependency graph + +```mermaid +flowchart BT + core["modulo_core +Result<T> (std::expected) +version() · QString-based"] + api["modulo_api +Q_GADGET DTOs +Health / Error +api::json::require*"] + cfg["modulo_server_config"] + httpm["modulo_server_http"] + dbm["modulo_server_db +(Qt-free · libpqxx)"] + + server(["modulo_server (exe)"]) + migrateexe(["modulo_migrate (exe, Qt-free)"]) + clientexe(["modulo_client (exe)"]) + + api --> core + cfg --> core + httpm --> api + httpm --> cfg + server --> httpm + migrateexe --> dbm + clientexe --> api + + qt["Qt6: Core · Network · HttpServer · Quick"] + pqxx["libpqxx 8"] + httpm -.-> qt + clientexe -.-> qt + core -.-> qt + dbm -.-> pqxx +``` + +Every server-side module is its own static library (`CMakeLists.txt` + `include/modulo/...` + `src/`), +created by the `modulo_*` CMake toolkit functions (warnings, sanitizers, clang-tidy, version +injection, qt.conf generation applied uniformly). Coming next: `modulo_server_auth` (Increment 2), +then transactions / transfers / holdings / rates / documents as sibling modules. + +## 3. Runtime flow — health check + +```mermaid +sequenceDiagram + participant Q as Main.qml (Timer 3 s) + participant A as ApiClient + participant S as QHttpServer route + participant D as modulo_api DTOs + + Q->>A: checkHealth() + A->>S: GET /api/v1/health + S->>D: HealthResponse{ok, 0.1.0}.toJson() + S-->>A: 200 {"status":"ok","version":"0.1.0"} + A->>D: HealthResponse::fromJson (validating) + D-->>A: Result of HealthResponse or Error + A-->>Q: serverReachable / serverStatus properties + Note over Q: green pulsing dot · "server ok (v0.1.0)" +``` + +## 4. Runtime flow — migrations + +```mermaid +sequenceDiagram + participant U as scripts/migrate.sh + participant M as modulo_migrate + participant G as Migrator (db module) + participant P as PostgreSQL 16 + + U->>M: MODULO_DB_URL + --dir db/migrations + M->>G: run() + G->>G: discover() — NNNN_name.sql, sorted, dup check + G->>P: ensure schema_migrations + loop each migration (one transaction) + G->>P: md5(content) — checksum via Postgres + alt already applied, checksum matches + G->>G: skip + else checksum differs + G-->>M: MigrationError (append-only violated) + else pending + G->>P: apply SQL + record row, commit + end + end + M-->>U: "N applied, M skipped" (exit code) +``` + +## 5. Test architecture + +```mermaid +flowchart LR + subgraph unit["label: unit — Qt Test, no Docker"] + t1["modulo_core_tests"] + t2["modulo_api_health_dto_tests +modulo_api_error_dto_tests +modulo_api_json_tests"] + t3["modulo_server_config_tests"] + end + subgraph integ["label: integration — opt-in"] + t4["modulo_integration_tests +in-process QHttpServer on port 0 ++ QNetworkAccessManager client"] + end + subgraph ui["label: ui — Qt Quick Test, offscreen"] + t5["modulo_client_qml_tests +tst_*.qml via QUICK_TEST_MAIN"] + end + + env["MODULO_TEST_DB_URL"] -. "unset → QSKIP → CTest Skipped" .-> t4 + support["tests/support/include/modulo/testing/ +integration.h: MODULO_REQUIRE_TEST_DATABASE(), httpGet()"] --> t4 + + presets["ctest --preset unit | integration | ui | all"] --> unit + presets --> integ + presets --> ui +``` + +Conventions: one `QObject` test class per binary (`QTEST_GUILESS_MAIN`), data-driven rows via +`_data()` slots; each target's `tests/` directory is auto-discovered by the CMake toolkit, which +links `Qt6::Test`, adds the shared support include dir, and maps Qt Test's `SKIP :` output to +CTest's *Skipped* status. Cross-module integration tests live only in `server/tests/integration/`. diff --git a/libs/api/CMakeLists.txt b/libs/api/CMakeLists.txt new file mode 100644 index 0000000..36fda59 --- /dev/null +++ b/libs/api/CMakeLists.txt @@ -0,0 +1,7 @@ +# modulo_api — request/response DTOs and their JSON mappings, shared verbatim +# by the server and every client so both sides agree on the wire format. + +modulo_add_library( + modulo_api + SOURCES src/error.cpp src/health.cpp src/json.cpp + PUBLIC_DEPS modulo_core Qt6::Core) diff --git a/libs/api/include/modulo/api/error.h b/libs/api/include/modulo/api/error.h new file mode 100644 index 0000000..d78fac8 --- /dev/null +++ b/libs/api/include/modulo/api/error.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include +#include + +namespace modulo::api { + +/// Uniform error envelope used by every API endpoint: +/// {"error": {"code": "", "message": ""}} +/// Clients branch on `code`; `message` is for humans and logs only. +struct ErrorResponse { + Q_GADGET + Q_PROPERTY(QString code MEMBER code) + Q_PROPERTY(QString message MEMBER message) + +public: + QString code; + QString message; + + QJsonObject toJson() const; + static core::Result fromJson(const QJsonObject& json); +}; + +} // namespace modulo::api diff --git a/libs/api/include/modulo/api/health.h b/libs/api/include/modulo/api/health.h new file mode 100644 index 0000000..0a31ecb --- /dev/null +++ b/libs/api/include/modulo/api/health.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include +#include + +namespace modulo::api { + +/// Response body of GET /api/v1/health. +/// +/// DTO conventions (all Modulo DTOs follow this shape): a Q_GADGET struct — +/// QML-readable by value, no QObject overhead — with an explicit, validating +/// QJson mapping. fromJson() rejects missing/mistyped fields via +/// api::json::require* instead of QJson's silent defaults. +struct HealthResponse { + Q_GADGET + Q_PROPERTY(QString status MEMBER status) + Q_PROPERTY(QString version MEMBER version) + +public: + QString status; ///< "ok" when the server is serving requests. + QString version; ///< Server semantic version. + + QJsonObject toJson() const; + static core::Result fromJson(const QJsonObject& json); +}; + +} // namespace modulo::api diff --git a/libs/api/include/modulo/api/json.h b/libs/api/include/modulo/api/json.h new file mode 100644 index 0000000..114e00d --- /dev/null +++ b/libs/api/include/modulo/api/json.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include +#include + +namespace modulo::api::json { + +/// Required-field accessors for wire data. +/// +/// QJson's own reads default silently (a missing key yields an empty value); +/// wire parsing must never do that. These helpers make every absent or +/// wrongly-typed field an explicit Error with code "api.invalid_field". + +core::Result requireString(const QJsonObject& object, QLatin1StringView key); + +core::Result requireObject(const QJsonObject& object, QLatin1StringView key); + +} // namespace modulo::api::json diff --git a/libs/api/src/error.cpp b/libs/api/src/error.cpp new file mode 100644 index 0000000..f17baf9 --- /dev/null +++ b/libs/api/src/error.cpp @@ -0,0 +1,32 @@ +#include +#include + +#include + +namespace modulo::api { + +QJsonObject ErrorResponse::toJson() const { + return QJsonObject{ + {QStringLiteral("error"), QJsonObject{{QStringLiteral("code"), code}, {QStringLiteral("message"), message}}}}; +} + +core::Result ErrorResponse::fromJson(const QJsonObject& json) { + auto envelope = json::requireObject(json, QLatin1StringView{"error"}); + if (!envelope) { + return std::unexpected{std::move(envelope).error()}; + } + + auto code = json::requireString(*envelope, QLatin1StringView{"code"}); + if (!code) { + return std::unexpected{std::move(code).error()}; + } + + auto message = json::requireString(*envelope, QLatin1StringView{"message"}); + if (!message) { + return std::unexpected{std::move(message).error()}; + } + + return ErrorResponse{.code = std::move(*code), .message = std::move(*message)}; +} + +} // namespace modulo::api diff --git a/libs/api/src/health.cpp b/libs/api/src/health.cpp new file mode 100644 index 0000000..6756ed9 --- /dev/null +++ b/libs/api/src/health.cpp @@ -0,0 +1,26 @@ +#include +#include + +#include + +namespace modulo::api { + +QJsonObject HealthResponse::toJson() const { + return QJsonObject{{QStringLiteral("status"), status}, {QStringLiteral("version"), version}}; +} + +core::Result HealthResponse::fromJson(const QJsonObject& json) { + auto status = json::requireString(json, QLatin1StringView{"status"}); + if (!status) { + return std::unexpected{std::move(status).error()}; + } + + auto version = json::requireString(json, QLatin1StringView{"version"}); + if (!version) { + return std::unexpected{std::move(version).error()}; + } + + return HealthResponse{.status = std::move(*status), .version = std::move(*version)}; +} + +} // namespace modulo::api diff --git a/libs/api/src/json.cpp b/libs/api/src/json.cpp new file mode 100644 index 0000000..5979845 --- /dev/null +++ b/libs/api/src/json.cpp @@ -0,0 +1,25 @@ +#include + +#include + +namespace modulo::api::json { + +core::Result requireString(const QJsonObject& object, QLatin1StringView key) { + const QJsonValue value = object.value(key); + if (!value.isString()) { + return core::makeError(QStringLiteral("api.invalid_field"), + QStringLiteral("missing or non-string field '%1'").arg(key)); + } + return value.toString(); +} + +core::Result requireObject(const QJsonObject& object, QLatin1StringView key) { + const QJsonValue value = object.value(key); + if (!value.isObject()) { + return core::makeError(QStringLiteral("api.invalid_field"), + QStringLiteral("missing or non-object field '%1'").arg(key)); + } + return value.toObject(); +} + +} // namespace modulo::api::json diff --git a/libs/api/tests/CMakeLists.txt b/libs/api/tests/CMakeLists.txt new file mode 100644 index 0000000..4aba1bb --- /dev/null +++ b/libs/api/tests/CMakeLists.txt @@ -0,0 +1,17 @@ +modulo_add_test( + modulo_api_health_dto_tests + LABEL unit + SOURCES test_health_dto.cpp + DEPS modulo_api) + +modulo_add_test( + modulo_api_error_dto_tests + LABEL unit + SOURCES test_error_dto.cpp + DEPS modulo_api) + +modulo_add_test( + modulo_api_json_tests + LABEL unit + SOURCES test_json.cpp + DEPS modulo_api) diff --git a/libs/api/tests/test_error_dto.cpp b/libs/api/tests/test_error_dto.cpp new file mode 100644 index 0000000..fe503bd --- /dev/null +++ b/libs/api/tests/test_error_dto.cpp @@ -0,0 +1,55 @@ +#include + +#include +#include + +using modulo::api::ErrorResponse; + +class ErrorDtoTest : public QObject { + Q_OBJECT + +private slots: + + void serializesToTheUniformEnvelope() { + const ErrorResponse error{.code = QStringLiteral("not_found"), .message = QStringLiteral("resource not found")}; + + const QJsonObject json = error.toJson(); + QCOMPARE(json.size(), 1); // nothing outside the "error" envelope + const QJsonObject envelope = json.value(QLatin1StringView{"error"}).toObject(); + QCOMPARE(envelope.value(QLatin1StringView{"code"}).toString(), QStringLiteral("not_found")); + QCOMPARE(envelope.value(QLatin1StringView{"message"}).toString(), QStringLiteral("resource not found")); + + const auto parsed = ErrorResponse::fromJson(json); + QVERIFY(parsed.has_value()); + QCOMPARE(parsed->code, error.code); + QCOMPARE(parsed->message, error.message); + } + + void rejectsInvalidWireData_data() { + QTest::addColumn("json"); + QTest::addColumn("offendingField"); + + QTest::newRow("flat object without the envelope") + << QJsonObject{{QStringLiteral("code"), QStringLiteral("x")}, + {QStringLiteral("message"), QStringLiteral("y")}} + << QStringLiteral("error"); + QTest::newRow("envelope missing the code") + << QJsonObject{{QStringLiteral("error"), QJsonObject{{QStringLiteral("message"), QStringLiteral("y")}}}} + << QStringLiteral("code"); + QTest::newRow("envelope is not an object") + << QJsonObject{{QStringLiteral("error"), QStringLiteral("oops")}} << QStringLiteral("error"); + } + + void rejectsInvalidWireData() { + QFETCH(QJsonObject, json); + QFETCH(QString, offendingField); + + const auto result = ErrorResponse::fromJson(json); + QVERIFY(!result.has_value()); + QCOMPARE(result.error().code, QStringLiteral("api.invalid_field")); + QVERIFY(result.error().message.contains(offendingField)); + } +}; + +QTEST_GUILESS_MAIN(ErrorDtoTest) +#include "test_error_dto.moc" diff --git a/libs/api/tests/test_health_dto.cpp b/libs/api/tests/test_health_dto.cpp new file mode 100644 index 0000000..9912573 --- /dev/null +++ b/libs/api/tests/test_health_dto.cpp @@ -0,0 +1,50 @@ +#include + +#include +#include + +using modulo::api::HealthResponse; + +class HealthDtoTest : public QObject { + Q_OBJECT + +private slots: + + void roundTripsThroughJson() { + const HealthResponse original{.status = QStringLiteral("ok"), .version = QStringLiteral("1.2.3")}; + + const QJsonObject json = original.toJson(); + QCOMPARE(json.value(QLatin1StringView{"status"}).toString(), QStringLiteral("ok")); + QCOMPARE(json.value(QLatin1StringView{"version"}).toString(), QStringLiteral("1.2.3")); + + const auto parsed = HealthResponse::fromJson(json); + QVERIFY(parsed.has_value()); + QCOMPARE(parsed->status, original.status); + QCOMPARE(parsed->version, original.version); + } + + void rejectsInvalidWireData_data() { + QTest::addColumn("json"); + QTest::addColumn("offendingField"); + + QTest::newRow("missing version") << QJsonObject{{QStringLiteral("status"), QStringLiteral("ok")}} + << QStringLiteral("version"); + QTest::newRow("status is a number, not coerced") + << QJsonObject{{QStringLiteral("status"), 42}, {QStringLiteral("version"), QStringLiteral("1.0.0")}} + << QStringLiteral("status"); + QTest::newRow("empty object") << QJsonObject{} << QStringLiteral("status"); + } + + void rejectsInvalidWireData() { + QFETCH(QJsonObject, json); + QFETCH(QString, offendingField); + + const auto result = HealthResponse::fromJson(json); + QVERIFY(!result.has_value()); + QCOMPARE(result.error().code, QStringLiteral("api.invalid_field")); + QVERIFY(result.error().message.contains(offendingField)); + } +}; + +QTEST_GUILESS_MAIN(HealthDtoTest) +#include "test_health_dto.moc" diff --git a/libs/api/tests/test_json.cpp b/libs/api/tests/test_json.cpp new file mode 100644 index 0000000..d108bcd --- /dev/null +++ b/libs/api/tests/test_json.cpp @@ -0,0 +1,61 @@ +#include + +#include +#include +#include + +namespace json = modulo::api::json; + +class JsonHelpersTest : public QObject { + Q_OBJECT + +private slots: + + void requireStringReturnsPresentStrings() { + const QJsonObject object{{QStringLiteral("name"), QStringLiteral("modulo")}}; + + const auto value = json::requireString(object, QLatin1StringView{"name"}); + QVERIFY(value.has_value()); + QCOMPARE(*value, QStringLiteral("modulo")); + } + + void requireStringNeverUsesSilentDefaults_data() { + QTest::addColumn("key"); + + // Every non-string shape QJson would otherwise coerce to "". + QTest::newRow("missing key") << QStringLiteral("missing"); + QTest::newRow("number") << QStringLiteral("count"); + QTest::newRow("object") << QStringLiteral("nested"); + QTest::newRow("array") << QStringLiteral("list"); + QTest::newRow("null") << QStringLiteral("nothing"); + } + + void requireStringNeverUsesSilentDefaults() { + QFETCH(QString, key); + const QJsonObject object{{QStringLiteral("count"), 3}, + {QStringLiteral("nested"), QJsonObject{}}, + {QStringLiteral("list"), QJsonArray{}}, + {QStringLiteral("nothing"), QJsonValue::Null}}; + + const auto value = json::requireString(object, QLatin1StringView{key.toLatin1()}); + QVERIFY(!value.has_value()); + QCOMPARE(value.error().code, QStringLiteral("api.invalid_field")); + QVERIFY(value.error().message.contains(key)); + } + + void requireObjectDistinguishesObjects() { + const QJsonObject object{{QStringLiteral("inner"), QJsonObject{{QStringLiteral("k"), QStringLiteral("v")}}}, + {QStringLiteral("text"), QStringLiteral("not an object")}}; + + const auto inner = json::requireObject(object, QLatin1StringView{"inner"}); + QVERIFY(inner.has_value()); + QCOMPARE(inner->value(QLatin1StringView{"k"}).toString(), QStringLiteral("v")); + + const auto text = json::requireObject(object, QLatin1StringView{"text"}); + QVERIFY(!text.has_value()); + QCOMPARE(text.error().code, QStringLiteral("api.invalid_field")); + } +}; + +QTEST_GUILESS_MAIN(JsonHelpersTest) +#include "test_json.moc" diff --git a/libs/core/CMakeLists.txt b/libs/core/CMakeLists.txt new file mode 100644 index 0000000..3d170b8 --- /dev/null +++ b/libs/core/CMakeLists.txt @@ -0,0 +1,6 @@ +# modulo_core — domain foundations shared by every other target. + +modulo_add_library( + modulo_core + SOURCES src/version.cpp + PUBLIC_DEPS Qt6::Core) diff --git a/libs/core/include/modulo/core/result.h b/libs/core/include/modulo/core/result.h new file mode 100644 index 0000000..9833898 --- /dev/null +++ b/libs/core/include/modulo/core/result.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include +#include + +namespace modulo::core { + +/// Error value carried by Result. +/// +/// `code` is a stable, machine-readable identifier in dotted-snake form +/// (e.g. "config.invalid_port", "http.bind_failed") — it is what tests and +/// API clients match on. `message` is human-readable detail and carries no +/// stability guarantee. +struct Error { + QString code; + QString message; +}; + +/// Project-wide result type: a value of T or an Error. +/// +/// Used instead of exceptions on expected failure paths (bad input, +/// unavailable resources). Exceptions remain for genuinely exceptional, +/// non-recoverable situations. +template +using Result = std::expected; + +/// Result for operations that produce no value on success. +using VoidResult = std::expected; + +/// Convenience factory: `return makeError("config.invalid_port", "...");` +/// converts implicitly to any Result. +inline std::unexpected makeError(QString code, QString message) { + return std::unexpected{Error{std::move(code), std::move(message)}}; +} + +} // namespace modulo::core diff --git a/libs/core/include/modulo/core/version.h b/libs/core/include/modulo/core/version.h new file mode 100644 index 0000000..6108585 --- /dev/null +++ b/libs/core/include/modulo/core/version.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace modulo::core { + +/// Semantic version of the Modulo project, e.g. "0.1.0". Single source of +/// truth is the root CMake project() call (injected at compile time). +QString version(); + +} // namespace modulo::core diff --git a/libs/core/src/version.cpp b/libs/core/src/version.cpp new file mode 100644 index 0000000..5af5f92 --- /dev/null +++ b/libs/core/src/version.cpp @@ -0,0 +1,9 @@ +#include + +namespace modulo::core { + +QString version() { + return QStringLiteral(MODULO_VERSION); +} + +} // namespace modulo::core diff --git a/libs/core/tests/CMakeLists.txt b/libs/core/tests/CMakeLists.txt new file mode 100644 index 0000000..50319af --- /dev/null +++ b/libs/core/tests/CMakeLists.txt @@ -0,0 +1,5 @@ +modulo_add_test( + modulo_core_tests + LABEL unit + SOURCES test_version.cpp + DEPS modulo_core) diff --git a/libs/core/tests/test_version.cpp b/libs/core/tests/test_version.cpp new file mode 100644 index 0000000..d6a56f8 --- /dev/null +++ b/libs/core/tests/test_version.cpp @@ -0,0 +1,23 @@ +#include + +#include +#include + +class VersionTest : public QObject { + Q_OBJECT + +private slots: + + void reportsTheCMakeProjectVersion() { + // Both the library and this test receive MODULO_VERSION from the toolkit. + QCOMPARE(modulo::core::version(), QStringLiteral(MODULO_VERSION)); + } + + void hasSemanticVersionShape() { + const QRegularExpression semver{QStringLiteral(R"(^\d+\.\d+\.\d+$)")}; + QVERIFY(semver.match(modulo::core::version()).hasMatch()); + } +}; + +QTEST_GUILESS_MAIN(VersionTest) +#include "test_version.moc" diff --git a/scripts/db-down.sh b/scripts/db-down.sh new file mode 100755 index 0000000..402df54 --- /dev/null +++ b/scripts/db-down.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Stop the dockerized Modulo development database. +# +# Usage: +# scripts/db-down.sh # stop; data volume is preserved +# scripts/db-down.sh --wipe # stop AND delete all database data +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +DOWN_ARGS=() +if [[ "${1:-}" == "--wipe" ]]; then + read -r -p "This DELETES all data in the dev and test databases. Continue? [y/N] " reply + [[ "${reply}" == "y" || "${reply}" == "Y" ]] || { echo "aborted"; exit 1; } + DOWN_ARGS=(--volumes) +fi + +docker compose -f "${REPO_ROOT}/docker/docker-compose.yml" down "${DOWN_ARGS[@]+"${DOWN_ARGS[@]}"}" + +echo "db-down.sh: done" diff --git a/scripts/db-up.sh b/scripts/db-up.sh new file mode 100755 index 0000000..49f900c --- /dev/null +++ b/scripts/db-up.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Start the dockerized Modulo development database (Postgres 16 on localhost:5433) +# and wait until it is healthy. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! docker info > /dev/null 2>&1; then + echo "error: Docker is not running (open -a Docker)" >&2 + exit 1 +fi + +docker compose -f "${REPO_ROOT}/docker/docker-compose.yml" up -d --wait + +echo "db-up.sh: postgres ready on localhost:5433 (databases: modulo_dev, modulo_test)" diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 0000000..8807109 --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Format all first-party C++ sources with clang-format (in place). +# +# Usage: +# scripts/format.sh # rewrite files +# scripts/format.sh --check # verify only; exit non-zero if formatting differs (CI mode) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Homebrew LLVM is keg-only, so its tools are not on PATH by default. +CLANG_FORMAT="${CLANG_FORMAT:-/opt/homebrew/opt/llvm/bin/clang-format}" + +if [[ ! -x "${CLANG_FORMAT}" ]]; then + echo "error: clang-format not found at ${CLANG_FORMAT} (brew install llvm)" >&2 + exit 1 +fi + +MODE_ARGS=(-i) +if [[ "${1:-}" == "--check" ]]; then + MODE_ARGS=(--dry-run --Werror) +fi + +# First-party source directories only — never third-party or generated code. +SEARCH_DIRS=() +for dir in libs server client; do + [[ -d "${REPO_ROOT}/${dir}" ]] && SEARCH_DIRS+=("${REPO_ROOT}/${dir}") +done +if [[ ${#SEARCH_DIRS[@]} -gt 0 ]]; then + find "${SEARCH_DIRS[@]}" \ + -type f \( -name '*.cpp' -o -name '*.h' \) -print0 | + xargs -0 -r "${CLANG_FORMAT}" --style=file "${MODE_ARGS[@]}" +fi + +echo "format.sh: done" diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100755 index 0000000..0227a45 --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Apply pending database migrations using the modulo_migrate binary. +# +# The database URL comes from, in order: an existing MODULO_DB_URL in the +# environment, the repo-root .env file, or the dev-database default. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ -z "${MODULO_DB_URL:-}" && -f "${REPO_ROOT}/.env" ]]; then + set -a + # shellcheck source=/dev/null + source "${REPO_ROOT}/.env" + set +a +fi +export MODULO_DB_URL="${MODULO_DB_URL:-postgresql://modulo:modulo@localhost:5433/modulo_dev}" + +MIGRATE_BIN="${REPO_ROOT}/build/dev/server/migrate/modulo_migrate" +if [[ ! -x "${MIGRATE_BIN}" ]]; then + echo "error: ${MIGRATE_BIN} not found — build it first:" >&2 + echo " cmake --preset dev && cmake --build --preset dev" >&2 + exit 1 +fi + +exec "${MIGRATE_BIN}" --dir "${REPO_ROOT}/db/migrations" diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt new file mode 100644 index 0000000..7c741e9 --- /dev/null +++ b/server/CMakeLists.txt @@ -0,0 +1,11 @@ +# Server-side modules (one static library each) and executables. + +add_subdirectory(modules/config) +add_subdirectory(modules/db) +add_subdirectory(modules/http) + +add_subdirectory(app) +add_subdirectory(migrate) + +# Cross-module integration tests (label: integration). +add_subdirectory(tests/integration) diff --git a/server/app/CMakeLists.txt b/server/app/CMakeLists.txt new file mode 100644 index 0000000..6530d30 --- /dev/null +++ b/server/app/CMakeLists.txt @@ -0,0 +1,6 @@ +# modulo_server — the REST API server executable. + +modulo_add_executable( + modulo_server + SOURCES main.cpp + DEPS modulo_server_http Qt6::Core) diff --git a/server/app/main.cpp b/server/app/main.cpp new file mode 100644 index 0000000..f1ee167 --- /dev/null +++ b/server/app/main.cpp @@ -0,0 +1,33 @@ +// modulo_server — Modulo REST API server. +// +// Configuration from environment variables (see .env.example). +// Runs until interrupted; serves on 127.0.0.1 only. + +#include +#include +#include + +#include + +#include + +int main(int argc, char* argv[]) { + QCoreApplication app{argc, argv}; + + const auto config = modulo::server::config::Config::fromEnvironment(); + if (!config) { + qCritical().noquote() << QStringLiteral("error [%1]: %2").arg(config.error().code, config.error().message); + return EXIT_FAILURE; + } + + modulo::server::http::Server server{*config}; + const auto port = server.listen(); + if (!port) { + qCritical().noquote() << QStringLiteral("error [%1]: %2").arg(port.error().code, port.error().message); + return EXIT_FAILURE; + } + + qInfo().noquote() + << QStringLiteral("modulo_server v%1 listening on http://127.0.0.1:%2").arg(modulo::core::version()).arg(*port); + return app.exec(); +} diff --git a/server/migrate/CMakeLists.txt b/server/migrate/CMakeLists.txt new file mode 100644 index 0000000..c4f155b --- /dev/null +++ b/server/migrate/CMakeLists.txt @@ -0,0 +1,6 @@ +# modulo_migrate — command-line migration runner (scripts/migrate.sh wraps it). + +modulo_add_executable( + modulo_migrate + SOURCES main.cpp + DEPS modulo_server_db) diff --git a/server/migrate/main.cpp b/server/migrate/main.cpp new file mode 100644 index 0000000..1635799 --- /dev/null +++ b/server/migrate/main.cpp @@ -0,0 +1,80 @@ +// modulo_migrate — command-line migration runner. +// +// Usage: +// modulo_migrate [--url ] [--dir ] +// +// The database URL falls back to the MODULO_DB_URL environment variable; +// the migrations directory defaults to db/migrations relative to the +// current working directory (scripts/migrate.sh passes it explicitly). + +#include + +#include +#include +#include +#include +#include + +namespace { + +struct Options { + std::string url; + std::string dir = "db/migrations"; + bool help = false; +}; + +Options parseArguments(std::span args) { + Options options; + if (const char* env = std::getenv("MODULO_DB_URL")) { + options.url = env; + } + + for (std::size_t i = 1; i < args.size(); ++i) { + const std::string_view arg{args[i]}; + if (arg == "--help" || arg == "-h") { + options.help = true; + } else if (arg == "--url" && i + 1 < args.size()) { + options.url = args[++i]; + } else if (arg == "--dir" && i + 1 < args.size()) { + options.dir = args[++i]; + } else { + throw std::invalid_argument(std::string{"unknown or incomplete argument: "} + std::string{arg}); + } + } + return options; +} + +} // namespace + +int main(int argc, char* argv[]) { + Options options; + try { + options = parseArguments(std::span{argv, static_cast(argc)}); + } catch (const std::invalid_argument& error) { + std::println(stderr, "error: {}", error.what()); + return EXIT_FAILURE; + } + + if (options.help) { + std::println("usage: modulo_migrate [--url ] [--dir ]"); + std::println(" --url defaults to $MODULO_DB_URL; --dir defaults to db/migrations"); + return EXIT_SUCCESS; + } + + if (options.url.empty()) { + std::println(stderr, "error: no database URL (pass --url or set MODULO_DB_URL)"); + return EXIT_FAILURE; + } + + try { + modulo::server::db::Migrator migrator{options.url, options.dir, + [](std::string_view line) { std::println("{}", line); }}; + const auto result = migrator.run(); + std::println("migrations: {} applied, {} skipped", result.applied, result.skipped); + return EXIT_SUCCESS; + } catch (const std::exception& error) { + // Never echo options.url here — it may contain credentials. + std::println(stderr, "error: {}", error.what()); + return EXIT_FAILURE; + } +} diff --git a/server/modules/config/CMakeLists.txt b/server/modules/config/CMakeLists.txt new file mode 100644 index 0000000..450c056 --- /dev/null +++ b/server/modules/config/CMakeLists.txt @@ -0,0 +1,6 @@ +# modulo_server_config — server process configuration from the environment. + +modulo_add_library( + modulo_server_config + SOURCES src/config.cpp + PUBLIC_DEPS modulo_core Qt6::Core) diff --git a/server/modules/config/include/modulo/server/config/config.h b/server/modules/config/include/modulo/server/config/config.h new file mode 100644 index 0000000..97b1a9b --- /dev/null +++ b/server/modules/config/include/modulo/server/config/config.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include +#include + +namespace modulo::server::config { + +/// Server process configuration sourced from environment variables +/// (documented in .env.example at the repo root). +struct Config { + /// MODULO_DB_URL. May be empty for features that do not touch the + /// database; features that need it validate at their own startup. + QString databaseUrl; + + /// MODULO_HTTP_PORT. Port 0 asks the OS for a free port (used by tests). + quint16 httpPort = 8080; + + /// MODULO_DATA_DIR — root for server-managed files (document uploads). + QString dataDir = QStringLiteral("./var/data"); + + /// Build a Config from the process environment. Unset or empty variables + /// keep their defaults; malformed values yield an Error whose code is + /// prefixed "config.". + static core::Result fromEnvironment(); +}; + +} // namespace modulo::server::config diff --git a/server/modules/config/src/config.cpp b/server/modules/config/src/config.cpp new file mode 100644 index 0000000..60ba3dd --- /dev/null +++ b/server/modules/config/src/config.cpp @@ -0,0 +1,34 @@ +#include + +namespace modulo::server::config { + +namespace { + +/// qEnvironmentVariable's own default only covers UNSET variables; an +/// exported-but-empty variable should fall back to the default too. +QString envOr(const char* name, const QString& fallback) { + const QString value = qEnvironmentVariable(name); + return value.isEmpty() ? fallback : value; +} + +} // namespace + +core::Result Config::fromEnvironment() { + Config config; + config.databaseUrl = envOr("MODULO_DB_URL", QString{}); + config.dataDir = envOr("MODULO_DATA_DIR", config.dataDir); + + const QString portText = envOr("MODULO_HTTP_PORT", QStringLiteral("8080")); + bool valid = false; + const quint16 port = portText.toUShort(&valid); // rejects non-numeric and > 65535 + if (!valid) { + return core::makeError( + QStringLiteral("config.invalid_port"), + QStringLiteral("MODULO_HTTP_PORT must be an integer in [0, 65535], got '%1'").arg(portText)); + } + config.httpPort = port; + + return config; +} + +} // namespace modulo::server::config diff --git a/server/modules/config/tests/CMakeLists.txt b/server/modules/config/tests/CMakeLists.txt new file mode 100644 index 0000000..f002880 --- /dev/null +++ b/server/modules/config/tests/CMakeLists.txt @@ -0,0 +1,5 @@ +modulo_add_test( + modulo_server_config_tests + LABEL unit + SOURCES test_config.cpp + DEPS modulo_server_config) diff --git a/server/modules/config/tests/test_config.cpp b/server/modules/config/tests/test_config.cpp new file mode 100644 index 0000000..4ee0254 --- /dev/null +++ b/server/modules/config/tests/test_config.cpp @@ -0,0 +1,115 @@ +#include + +#include +#include +#include + +#include +#include + +using modulo::server::config::Config; + +namespace { + +/// Sets MODULO_* variables for one test and restores the previous environment +/// on destruction, so tests cannot leak state into each other. +class ScopedEnvironment { +public: + explicit ScopedEnvironment(std::initializer_list> variables) { + for (const auto& [name, value] : variables) { + saved_.insert(name, qgetenv(name)); + if (value == nullptr) { + qunsetenv(name); + } else { + qputenv(name, value); + } + } + } + + ~ScopedEnvironment() { + for (auto it = saved_.cbegin(); it != saved_.cend(); ++it) { + if (it.value().isNull()) { + qunsetenv(it.key()); + } else { + qputenv(it.key(), it.value()); + } + } + } + + ScopedEnvironment(const ScopedEnvironment&) = delete; + ScopedEnvironment& operator=(const ScopedEnvironment&) = delete; + +private: + QHash saved_; +}; + +} // namespace + +class ConfigTest : public QObject { + Q_OBJECT + +private slots: + + void fallsBackToDefaultsWhenNothingIsSet() { + const ScopedEnvironment env{ + {"MODULO_DB_URL", nullptr}, {"MODULO_HTTP_PORT", nullptr}, {"MODULO_DATA_DIR", nullptr}}; + + const auto config = Config::fromEnvironment(); + QVERIFY(config.has_value()); + QVERIFY(config->databaseUrl.isEmpty()); + QCOMPARE(config->httpPort, quint16{8080}); + QCOMPARE(config->dataDir, QStringLiteral("./var/data")); + } + + void readsEveryVariable() { + const ScopedEnvironment env{{"MODULO_DB_URL", "postgresql://u:p@localhost:5433/db"}, + {"MODULO_HTTP_PORT", "9090"}, + {"MODULO_DATA_DIR", "/tmp/modulo-data"}}; + + const auto config = Config::fromEnvironment(); + QVERIFY(config.has_value()); + QCOMPARE(config->databaseUrl, QStringLiteral("postgresql://u:p@localhost:5433/db")); + QCOMPARE(config->httpPort, quint16{9090}); + QCOMPARE(config->dataDir, QStringLiteral("/tmp/modulo-data")); + } + + void treatsExportedButEmptyVariableAsUnset() { + const ScopedEnvironment env{{"MODULO_HTTP_PORT", ""}, {"MODULO_DATA_DIR", ""}}; + + const auto config = Config::fromEnvironment(); + QVERIFY(config.has_value()); + QCOMPARE(config->httpPort, quint16{8080}); + QCOMPARE(config->dataDir, QStringLiteral("./var/data")); + } + + void acceptsPortZeroForOsAssignedPorts() { + const ScopedEnvironment env{{"MODULO_HTTP_PORT", "0"}}; + + const auto config = Config::fromEnvironment(); + QVERIFY(config.has_value()); + QCOMPARE(config->httpPort, quint16{0}); + } + + void rejectsMalformedPorts_data() { + QTest::addColumn("port"); + + QTest::newRow("letters") << QByteArray{"abc"}; + QTest::newRow("out of range") << QByteArray{"70000"}; + QTest::newRow("negative") << QByteArray{"-1"}; + QTest::newRow("trailing garbage") << QByteArray{"80x"}; + QTest::newRow("fractional") << QByteArray{"8080.5"}; + } + + void rejectsMalformedPorts() { + QFETCH(QByteArray, port); + const ScopedEnvironment env{{"MODULO_HTTP_PORT", port.constData()}}; + + const auto config = Config::fromEnvironment(); + QVERIFY(!config.has_value()); + QCOMPARE(config.error().code, QStringLiteral("config.invalid_port")); + QVERIFY(config.error().message.contains(QString::fromLatin1(port))); + } +}; + +QTEST_GUILESS_MAIN(ConfigTest) +#include "test_config.moc" diff --git a/server/modules/db/CMakeLists.txt b/server/modules/db/CMakeLists.txt new file mode 100644 index 0000000..3b78e52 --- /dev/null +++ b/server/modules/db/CMakeLists.txt @@ -0,0 +1,6 @@ +# modulo_server_db — database access module and migration engine. + +modulo_add_library( + modulo_server_db + SOURCES src/migrator.cpp + PRIVATE_DEPS libpqxx::pqxx) diff --git a/server/modules/db/include/modulo/server/db/migrator.h b/server/modules/db/include/modulo/server/db/migrator.h new file mode 100644 index 0000000..3012cfc --- /dev/null +++ b/server/modules/db/include/modulo/server/db/migrator.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace modulo::server::db { + +/// Thrown when migration discovery or application fails. The migration that +/// caused the failure is named in the message; the database is left as of the +/// last successfully committed migration. +class MigrationError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +/// A migration file discovered on disk. Files live in db/migrations/ and are +/// named NNNN_name.sql (four-digit version, underscore, snake_case name). +struct Migration { + int version = 0; + std::string name; + std::filesystem::path path; +}; + +/// Outcome of a Migrator::run() invocation. +struct MigrationResult { + int applied = 0; + int skipped = 0; +}; + +/// Applies SQL migration files to a PostgreSQL database. +/// +/// State is tracked in the schema_migrations table: +/// one row per applied migration with its version, name, content checksum, +/// and timestamp. +/// +/// Rules: +/// - migrations run in ascending version order, each inside one transaction; +/// - an already-applied migration whose file is unchanged is skipped; +/// - an already-applied migration whose file content changed aborts the run +/// (migrations are append-only; never edit an applied file); +/// - a failing migration rolls back and aborts; nothing after it runs. +class Migrator { +public: + /// Receives one human-readable progress line per migration. + using Logger = std::function; + + Migrator(std::string databaseUrl, std::filesystem::path migrationsDir, Logger logger = {}); + + /// Scan the migrations directory. Non-dot files that do not match the + /// NNNN_name.sql pattern and duplicate versions raise MigrationError. + /// Returns migrations sorted by ascending version. + std::vector discover() const; + + /// Apply every pending migration. Throws MigrationError (see class docs) + /// or pqxx errors on connection failure. + MigrationResult run(); + +private: + void log(std::string_view message) const; + + std::string databaseUrl_; + std::filesystem::path migrationsDir_; + Logger logger_; +}; + +} // namespace modulo::server::db diff --git a/server/modules/db/src/migrator.cpp b/server/modules/db/src/migrator.cpp new file mode 100644 index 0000000..3e36b61 --- /dev/null +++ b/server/modules/db/src/migrator.cpp @@ -0,0 +1,154 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace modulo::server::db { + +namespace { + +constexpr std::string_view kCreateSchemaMigrations = R"sql( + CREATE TABLE IF NOT EXISTS schema_migrations ( + version integer PRIMARY KEY, + name text NOT NULL, + checksum text NOT NULL, + applied_at timestamptz NOT NULL DEFAULT now() + ) +)sql"; + +/// Parse "NNNN_name.sql" into (version, name); std::nullopt if the pattern +/// does not match. +std::optional> parseFilename(const std::string& filename) { + constexpr std::string_view kSuffix = ".sql"; + constexpr std::size_t kVersionDigits = 4; + // Shortest valid: "0000_x.sql" + if (filename.size() < kVersionDigits + 1 + 1 + kSuffix.size() || !filename.ends_with(kSuffix)) { + return std::nullopt; + } + if (filename[kVersionDigits] != '_') { + return std::nullopt; + } + + int version = 0; + const auto [ptr, ec] = std::from_chars(filename.data(), filename.data() + kVersionDigits, version); + if (ec != std::errc{} || ptr != filename.data() + kVersionDigits) { + return std::nullopt; + } + + std::string name = filename.substr(kVersionDigits + 1, filename.size() - kVersionDigits - 1 - kSuffix.size()); + return std::pair{version, std::move(name)}; +} + +std::string readFile(const std::filesystem::path& path) { + std::ifstream stream{path, std::ios::binary}; + if (!stream) { + throw MigrationError(std::format("cannot read migration file '{}'", path.string())); + } + std::ostringstream contents; + contents << stream.rdbuf(); + return std::move(contents).str(); +} + +/// Content checksum computed by PostgreSQL (md5 is fine here: this +/// detects accidental edits of applied files, it is not a security boundary). +std::string checksumOf(pqxx::work& tx, const std::string& sql) { + return tx.query_value("SELECT md5($1)", pqxx::params{sql}); +} + +} // namespace + +Migrator::Migrator(std::string databaseUrl, std::filesystem::path migrationsDir, Logger logger) + : databaseUrl_{std::move(databaseUrl)}, migrationsDir_{std::move(migrationsDir)}, logger_{std::move(logger)} { +} + +std::vector Migrator::discover() const { + if (!std::filesystem::is_directory(migrationsDir_)) { + throw MigrationError(std::format("migrations directory '{}' does not exist", migrationsDir_.string())); + } + + std::vector migrations; + for (const auto& entry : std::filesystem::directory_iterator{migrationsDir_}) { + const std::string filename = entry.path().filename().string(); + if (filename.starts_with('.')) { + continue; // tolerate .DS_Store and friends + } + + auto parsed = parseFilename(filename); + if (!parsed || !entry.is_regular_file()) { + throw MigrationError( + std::format("unexpected file '{}' in migrations directory (expected NNNN_name.sql)", filename)); + } + migrations.push_back({.version = parsed->first, .name = std::move(parsed->second), .path = entry.path()}); + } + + std::ranges::sort(migrations, {}, &Migration::version); + + const auto duplicate = std::ranges::adjacent_find(migrations, {}, &Migration::version); + if (duplicate != migrations.end()) { + throw MigrationError(std::format("duplicate migration version {:04}", duplicate->version)); + } + + return migrations; +} + +MigrationResult Migrator::run() { + const auto migrations = discover(); + + pqxx::connection connection{databaseUrl_}; + + { + pqxx::work tx{connection}; + tx.exec(kCreateSchemaMigrations); + tx.commit(); + } + + MigrationResult result; + for (const auto& migration : migrations) { + pqxx::work tx{connection}; + + const std::string sql = readFile(migration.path); + const std::string checksum = checksumOf(tx, sql); + + const auto known = + tx.exec("SELECT checksum FROM schema_migrations WHERE version = $1", pqxx::params{migration.version}); + if (!known.empty()) { + if (known[0][0].as() != checksum) { + throw MigrationError(std::format("migration {:04}_{} was applied with a different content checksum; " + "applied migration files are append-only and must never be edited", + migration.version, migration.name)); + } + ++result.skipped; + continue; // transaction aborts harmlessly + } + + try { + tx.exec(sql); + tx.exec("INSERT INTO schema_migrations (version, name, checksum) VALUES ($1, $2, $3)", + pqxx::params{migration.version, migration.name, checksum}); + tx.commit(); + } catch (const pqxx::sql_error& error) { + throw MigrationError(std::format("migration {:04}_{} failed and was rolled back: {}", migration.version, + migration.name, error.what())); + } + + ++result.applied; + log(std::format("applied {:04}_{}", migration.version, migration.name)); + } + + return result; +} + +void Migrator::log(std::string_view message) const { + if (logger_) { + logger_(message); + } +} + +} // namespace modulo::server::db diff --git a/server/modules/http/CMakeLists.txt b/server/modules/http/CMakeLists.txt new file mode 100644 index 0000000..8b3891b --- /dev/null +++ b/server/modules/http/CMakeLists.txt @@ -0,0 +1,8 @@ +# modulo_server_http — the REST API server: owns the QHttpServer, registers +# every route, and enforces the uniform JSON error envelope. + +modulo_add_library( + modulo_server_http + SOURCES src/server.cpp + PUBLIC_DEPS modulo_api modulo_server_config Qt6::HttpServer + PRIVATE_DEPS Qt6::Network) diff --git a/server/modules/http/include/modulo/server/http/server.h b/server/modules/http/include/modulo/server/http/server.h new file mode 100644 index 0000000..248bd5d --- /dev/null +++ b/server/modules/http/include/modulo/server/http/server.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +#include + +namespace modulo::server::http { + +/// The Modulo REST API server. +/// +/// Owns the QHttpServer instance and registers every route. Feature modules +/// contribute their routes here as increments land (auth, transactions, etc.). +/// Requires a running Qt event loop (QCoreApplication) to serve requests. +class Server { +public: + explicit Server(config::Config config); + + Server(const Server&) = delete; + Server& operator=(const Server&) = delete; + + /// Bind to 127.0.0.1 on config.httpPort (0 = OS-assigned, used by tests) + /// and start serving. Returns the actually bound port. + core::Result listen(); + +private: + void registerRoutes(); + + config::Config config_; + QHttpServer server_; +}; + +} // namespace modulo::server::http diff --git a/server/modules/http/src/server.cpp b/server/modules/http/src/server.cpp new file mode 100644 index 0000000..9b9d812 --- /dev/null +++ b/server/modules/http/src/server.cpp @@ -0,0 +1,66 @@ +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +namespace modulo::server::http { + +namespace { + +QByteArray toBody(const QJsonObject& json) { + return QJsonDocument{json}.toJson(QJsonDocument::Compact); +} + +QHttpServerResponse jsonResponse(const QJsonObject& body, QHttpServerResponse::StatusCode status) { + return QHttpServerResponse{"application/json", toBody(body), status}; +} + +} // namespace + +Server::Server(config::Config config) : config_{std::move(config)} { + registerRoutes(); +} + +core::Result Server::listen() { + // Bind to loopback only: in development the API must never be reachable + // from the network; production exposure goes through a reverse proxy. + auto tcpServer = std::make_unique(); + if (!tcpServer->listen(QHostAddress::LocalHost, config_.httpPort)) { + return core::makeError( + QStringLiteral("http.bind_failed"), + QStringLiteral("cannot listen on 127.0.0.1:%1: %2").arg(config_.httpPort).arg(tcpServer->errorString())); + } + + const quint16 port = tcpServer->serverPort(); + if (!server_.bind(tcpServer.get())) { + return core::makeError(QStringLiteral("http.bind_failed"), + QStringLiteral("QHttpServer refused the socket on port %1").arg(port)); + } + tcpServer.release(); // ownership transferred to server_ by bind() + + return port; +} + +void Server::registerRoutes() { + server_.route("/api/v1/health", QHttpServerRequest::Method::Get, [] { + const api::HealthResponse health{.status = QStringLiteral("ok"), .version = core::version()}; + return jsonResponse(health.toJson(), QHttpServerResponse::StatusCode::Ok); + }); + + // Anything unrouted gets the uniform error envelope instead of Qt's + // default HTML 404 page. + server_.setMissingHandler(&server_, [](const QHttpServerRequest&, QHttpServerResponder& responder) { + const api::ErrorResponse error{.code = QStringLiteral("not_found"), + .message = QStringLiteral("resource not found")}; + responder.write(toBody(error.toJson()), "application/json", QHttpServerResponder::StatusCode::NotFound); + }); +} + +} // namespace modulo::server::http diff --git a/server/tests/integration/CMakeLists.txt b/server/tests/integration/CMakeLists.txt new file mode 100644 index 0000000..d221a35 --- /dev/null +++ b/server/tests/integration/CMakeLists.txt @@ -0,0 +1,9 @@ +# Cross-module integration tests: real QHttpServer in-process, real HTTP +# client, and (from Increment 2) the real dockerized test database. +# Opt-in via MODULO_TEST_DB_URL — see tests/support/include/modulo/testing/integration.h. + +modulo_add_test( + modulo_integration_tests + LABEL integration + SOURCES test_health_endpoint.cpp + DEPS modulo_server_http Qt6::Network) diff --git a/server/tests/integration/test_health_endpoint.cpp b/server/tests/integration/test_health_endpoint.cpp new file mode 100644 index 0000000..e17fe0b --- /dev/null +++ b/server/tests/integration/test_health_endpoint.cpp @@ -0,0 +1,65 @@ +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +using namespace modulo; + +class HealthEndpointTest : public QObject { + Q_OBJECT + +private slots: + + /// Fresh server on an OS-assigned loopback port for every test function. + void init() { + server_ = std::make_unique(server::config::Config{.httpPort = 0}); + const auto port = server_->listen(); + QVERIFY(port.has_value()); + baseUrl_ = QUrl{QStringLiteral("http://127.0.0.1:%1").arg(*port)}; + } + + void cleanup() { server_.reset(); } + + void healthReportsOkAndTheServerVersion() { + MODULO_REQUIRE_TEST_DATABASE(); + + const auto response = testing::httpGet(url(QStringLiteral("/api/v1/health"))); + QCOMPARE(response.status, 200); + + const auto document = QJsonDocument::fromJson(response.body); + QVERIFY(document.isObject()); + const auto health = api::HealthResponse::fromJson(document.object()); + QVERIFY(health.has_value()); + QCOMPARE(health->status, QStringLiteral("ok")); + QCOMPARE(health->version, core::version()); + } + + void unknownRoutesAnswerWithTheJsonErrorEnvelope() { + MODULO_REQUIRE_TEST_DATABASE(); + + const auto response = testing::httpGet(url(QStringLiteral("/api/v1/does-not-exist"))); + QCOMPARE(response.status, 404); + + const auto document = QJsonDocument::fromJson(response.body); + QVERIFY(document.isObject()); + const auto error = api::ErrorResponse::fromJson(document.object()); + QVERIFY(error.has_value()); + QCOMPARE(error->code, QStringLiteral("not_found")); + } + +private: + QUrl url(const QString& path) const { return baseUrl_.resolved(QUrl{path}); } + + std::unique_ptr server_; + QUrl baseUrl_; +}; + +QTEST_GUILESS_MAIN(HealthEndpointTest) +#include "test_health_endpoint.moc" diff --git a/tests/support/include/modulo/testing/integration.h b/tests/support/include/modulo/testing/integration.h new file mode 100644 index 0000000..84e3740 --- /dev/null +++ b/tests/support/include/modulo/testing/integration.h @@ -0,0 +1,59 @@ +#pragma once + +// Fixtures shared by integration tests (server/tests/integration). +// +// Integration tests are OPT-IN: they run only when MODULO_TEST_DB_URL is set +// (see .env.example). Otherwise every test function QSKIPs, and CTest reports +// the binary as skipped (the toolkit maps Qt Test's "SKIP :" output line) — +// `ctest --preset unit` / `all` therefore never require Docker. +// +// Test binaries use QTEST_GUILESS_MAIN, which provides the QCoreApplication +// event loop that QHttpServer and QNetworkAccessManager need. + +#include +#include +#include +#include +#include +#include +#include +#include + +/// First statement of every integration test function: skips the test when +/// MODULO_TEST_DB_URL is unset. A macro because QSKIP must return from the +/// test function itself. +#define MODULO_REQUIRE_TEST_DATABASE() \ + if (modulo::testing::testDatabaseUrl().isEmpty()) { \ + QSKIP("MODULO_TEST_DB_URL is not set; integration tests are opt-in"); \ + } + +namespace modulo::testing { + +inline QString testDatabaseUrl() { + return qEnvironmentVariable("MODULO_TEST_DB_URL"); +} + +struct HttpResponse { + int status = 0; + QByteArray body; +}; + +/// Blocking HTTP GET against an in-process server, with a timeout so a dead +/// server fails the test instead of hanging it. +inline HttpResponse httpGet(const QUrl& url, int timeoutMs = 5000) { + QNetworkAccessManager network; + QNetworkReply* reply = network.get(QNetworkRequest{url}); + + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + QTimer::singleShot(timeoutMs, &loop, &QEventLoop::quit); + loop.exec(); + + HttpResponse response; + response.status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + response.body = reply->readAll(); + reply->deleteLater(); + return response; +} + +} // namespace modulo::testing