diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..01e3989 --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,71 @@ +# Runs only on pushes to main. +# Builds the Docker image, pushes it to the Forgejo container registry, +# then SSHes into the deploy host and restarts the service. +# +# Required secrets (set in Forgejo repo → Settings → Secrets): +# REGISTRY_USER — your Forgejo username +# REGISTRY_TOKEN — Forgejo personal access token (write:packages scope) +# DEPLOY_HOST — hostname or IP of the server running the app +# DEPLOY_USER — SSH username on that server +# DEPLOY_SSH_KEY — private SSH key (the public key must be in authorized_keys on the server) +# DEPLOY_PATH — absolute path to the directory containing docker-compose.yml +name: Build & Deploy + +on: + push: + branches: [main] + +env: + REGISTRY: git.fabledsword.com + IMAGE: git.fabledsword.com/bvandeusen/fabledassistant + +jobs: + build: + name: Build & push image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Forgejo registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.IMAGE }}:latest + ${{ env.IMAGE }}:${{ github.sha }} + # Registry-side layer cache — dramatically speeds up rebuilds. + # On first run there is no cache so it builds cold; after that + # unchanged layers (npm install, pip install) are reused. + cache-from: type=registry,ref=${{ env.IMAGE }}:cache + cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-latest + steps: + - name: Pull new image and restart + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -e + cd ${{ secrets.DEPLOY_PATH }} + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.fabledsword.com \ + -u "${{ secrets.REGISTRY_USER }}" --password-stdin + docker compose pull app + docker compose up -d --no-build app + docker image prune -f diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..e0e4e4e --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,62 @@ +# Runs on every push and pull request. +# Fast checks only — no Docker build. Second runs take ~30s due to caching. +name: CI + +on: + push: + pull_request: + +jobs: + typecheck: + name: TypeScript typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Type check + run: npx vue-tsc --noEmit + working-directory: frontend + + lint: + name: Python lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install ruff + run: pip install ruff + + - name: Lint + run: ruff check src/ + + test: + name: Python tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + cache-dependency-path: pyproject.toml + + - name: Install package with dev deps + run: pip install -e ".[dev]" + + - name: Run tests + run: pytest tests/ -v diff --git a/Makefile b/Makefile index c69081d..f9d672c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: build up down logs health migrate +.PHONY: build up down logs health migrate lint typecheck test fmt + +# --- Docker --- build: docker compose build @@ -17,3 +19,20 @@ health: migrate: docker compose exec app alembic upgrade head + +# --- Local dev (requires: pip install -e ".[dev]" and cd frontend && npm ci) --- + +lint: + ruff check src/ + +fmt: + ruff format src/ + +typecheck: + cd frontend && npx vue-tsc --noEmit + +test: + pytest tests/ -v + +# Run all checks in one shot (mirrors what CI does) +check: lint typecheck test diff --git a/pyproject.toml b/pyproject.toml index 41fe1e1..f83550e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,26 @@ dependencies = [ dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", + "ruff>=0.6", ] [tool.setuptools.packages.find] where = ["src"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.ruff] +line-length = 120 +target-version = "py312" + +[tool.ruff.lint] +# E = pycodestyle errors, F = pyflakes (catches unused imports, undefined names) +# Start conservative — add more rules as the codebase gains coverage. +select = ["E", "F"] +ignore = [ + "E501", # line too long — long lines are acceptable in this codebase + "E402", # module-level import not at top — common in Quart app factories + "F401", # imported but unused — some re-exports are intentional +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9210dee --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +""" +Shared pytest fixtures. + +Integration tests that need a real database should use a separate PostgreSQL +instance (e.g. a Docker service spun up by the CI job) and set DATABASE_URL +in the environment before importing the app. + +For unit tests of pure functions no database is needed at all. +""" +import os +import pytest + + +@pytest.fixture(autouse=True) +def _isolate_env(monkeypatch): + """Prevent tests from accidentally reading production env vars.""" + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("OLLAMA_URL", "http://localhost:11434") diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..2b496ce --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,33 @@ +""" +Unit tests for pure utility functions — no database or network required. +""" +import pytest +from fabledassistant.routes.export import _safe_filename + + +class TestSafeFilename: + def test_normal_title(self): + assert _safe_filename("My Note") == "My_Note" + + def test_strips_special_chars(self): + result = _safe_filename("Hello: World! (2024)") + assert ":" not in result + assert "!" not in result + assert "(" not in result + + def test_empty_title(self): + assert _safe_filename("") == "untitled" + + def test_none_title(self): + assert _safe_filename(None) == "untitled" + + def test_truncates_long_title(self): + long = "a" * 100 + assert len(_safe_filename(long)) <= 80 + + def test_preserves_hyphens(self): + assert "-" in _safe_filename("my-note-title") + + def test_collapses_whitespace(self): + result = _safe_filename("hello world") + assert " " not in result