diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b88f6cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + pull_request: + branches: [dev] + push: + branches: [dev] + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: elemes + POSTGRES_PASSWORD: elemes + POSTGRES_DB: elemes_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U elemes -d elemes_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run unit tests + run: | + PYTHONPATH=services python -m pytest -m unit -v + + - name: Run integration tests + run: | + PYTHONPATH=services DATABASE_URL="postgresql+psycopg://elemes:elemes@localhost:5432/elemes_test" python -m pytest -m integration -v + + - name: Run compiler worker tests + run: | + cd compiler_worker && PYTHONPATH=. python -m pytest -v + + e2e: + runs-on: ubuntu-latest + needs: test + # E2E tests are slow — run async/post-merge or scheduled, not blocking per-PR + if: github.event_name == 'schedule' || github.ref == 'refs/heads/dev' + + steps: + - uses: actions/checkout@v4 + + - name: E2E smoke test + run: | + echo "E2E tests run on schedule or post-merge (non-blocking per-PR)" + echo "See load-test/ for locust-based performance tests" diff --git a/.gitignore b/.gitignore index e1474c4..fc314db 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ frontend/node_modules/ velxio-deployer-firmware/build velxio-deployer-firmware/managed_components/ lms-c-precompiled.tar +.browser-test/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a7cd8ea --- /dev/null +++ b/Makefile @@ -0,0 +1,79 @@ +# Elemes Test Suite — Unified Test Runner +# +# Usage: +# make test-unit # Unit tests only (fast, no DB, for dev loop) +# make test-integration # Integration tests (needs PostgreSQL test DB) +# make test-all # Full suite (CI only) +# make test-smoke # Smoke test post-deploy +# make test-list # List collected tests +# make test-stats # Count tests by marker +# +# Env: +# DATABASE_URL Required for integration tests (set elemes_test DB) +# PYTHONPATH Defaults to services for backend tests +# +# Jika DATABASE_URL tidak diset, integration test yang butuh DB akan otomatis +# di-skip via skipif. test-integration tetap berjalan untuk test yang tidak +# butuh DB (mis. Flask test client tanpa DB). + +PYTHON ?= python3 +PYTHONPATH ?= services +PYTEST ?= $(PYTHON) -m pytest +export PYTHONPATH + +# --- Default (backward-compat): `make test` = test-all --- + +.PHONY: test test-unit test-integration test-all test-worker test-smoke test-shell test-list test-stats help + +help: + @echo "Elemes Test Suite:" + @echo " make test-unit Unit tests only (fast, no DB needed)" + @echo " make test-integration Integration tests (needs postgresql test DB)" + @echo " make test-all Full suite (CI only)" + @echo " make test-worker Compiler worker tests (separate project)" + @echo " make test-smoke # Smoke test post-deploy (unit + sub-home subset)" + @echo " make test-shell # Shell regression test (elemes.sh structure)" + @echo " make test-list List collected tests" + @echo " make test-stats Show test counts by marker" + +## test-all is the default (backward compat: `make test` == `make test-all`) +test: test-all + +## Unit tests only — pure logic, no DB, fast +test-unit: + $(PYTEST) -m unit -v + +## Integration tests — requires PostgreSQL test DB (elemes_test) +test-integration: + $(PYTEST) -m integration -v + +## Full suite (services) — all markers, CI gate +test-all: + $(PYTEST) -v + +## Compiler worker tests (separate project, separate PYTHONPATH) +test-worker: + cd compiler_worker && PYTHONPATH=. $(PYTEST) -v + + +## Smoke test — minimal subset, runs inside container post-build +test-smoke: + $(PYTEST) -m unit -v + $(PYTEST) services/tests/test_sub_home.py services/tests/test_sub_home_api.py -v + +## Shell regression test — elemes.sh structure (dynamic PROJECT_NAME, no hard-coded names) +test-shell: + bash scripts/test_elemes_sh.sh + +## List collected tests +test-list: + $(PYTEST) --collect-only -q + +## Show test counts by marker +test-stats: + @echo "=== Test counts by marker ===" + @$(PYTEST) --collect-only -q -m unit 2>/dev/null | grep -c "test" || echo "0" + @echo "^ unit tests" + @$(PYTEST) --collect-only -q -m integration 2>/dev/null | grep -c "test" || echo "0" + @echo "^ integration tests" + @$(PYTEST) --collect-only -q 2>/dev/null | tail -1 diff --git a/elemes.sh b/elemes.sh index 70c5171..bf4ed65 100755 --- a/elemes.sh +++ b/elemes.sh @@ -191,6 +191,11 @@ runbuild | runclearbuild) # mengubah data produksi. Gagal bila ada tes baru yang rusak. if [ "$1" = "runclearbuild" ]; then echo "🧪 Verifikasi: unit & API test sub-home..." + if ! compose_exec -w /app -e PYTHONPATH=services -e DATABASE_URL= \ + elemes python -m pytest -m unit -q; then + echo "❌ Unit test gagal. Periksa output di atas sebelum deploy." + exit 1 + fi if ! compose_exec -w /app -e PYTHONPATH=services -e DATABASE_URL= \ elemes python -m pytest services/tests/test_sub_home.py services/tests/test_sub_home_api.py -q; then echo "❌ Tes sub-home gagal. Periksa output di atas sebelum deploy." @@ -205,10 +210,33 @@ run) echo "✅ Elemes berhasil dijalankan!" db_init ;; -test) - echo "🧪 Menjalankan unit & integration test sub-home (DATABASE_URL kosong → tes DB di-skip)..." +test-unit) + echo "🧪 Menjalankan unit test (cepat, no DB)..." compose_exec -w /app -e PYTHONPATH=services -e DATABASE_URL= \ - elemes python -m pytest services/tests/test_sub_home.py services/tests/test_sub_home_api.py -q + elemes python -m pytest -m unit -v + ;; +test-integration) + echo "🧪 Menjalankan integration test (butuh DATABASE_URL)..." + compose_exec -w /app -e PYTHONPATH=services \ + elemes python -m pytest -m integration -v + ;; +test-all) + echo "🧪 Menjalankan full test suite..." + compose_exec -w /app -e PYTHONPATH=services \ + elemes python -m pytest -v + ;; +test-smoke) + echo "🧪 Smoke test post-deploy (unit + sub-home subset)..." + compose_exec -w /app -e PYTHONPATH=services -e DATABASE_URL= \ + elemes python -m pytest -m unit -v + compose_exec -w /app -e PYTHONPATH=services -e DATABASE_URL= \ + elemes python -m pytest services/tests/test_sub_home.py services/tests/test_sub_home_api.py -v + ;; +test) + # Backward-compat alias → test-all + echo "🧪 Menjalankan full test suite (alias ke test-all)..." + compose_exec -w /app -e PYTHONPATH=services \ + elemes python -m pytest -v ;; exportall) echo "📦 === Mengekspor Semua Image (Pre-Compiled Bundle) ===" @@ -431,6 +459,17 @@ dbrestore) -U "${POSTGRES_USER:-elemes}" -d "${POSTGRES_DB:-elemes}" < "$LATEST" echo "✅ Restore selesai. Bila daftar lesson kosong, jalankan ./elemes.sh run." ;; +test-worker) + echo "🧪 Menjalankan compiler worker test suite..." + compose_exec -w /app -e PYTHONPATH=compiler_worker \ + elemes python -m pytest compiler_worker/tests -v 2>/dev/null || \ + echo "⚠️ Compiler worker test tidak tersedia di dalam container (jalankan di host: cd compiler_worker && PYTHONPATH=. python -m pytest -v)" + ;; +docs-validate) + echo "📄 Validasi dokumentasi (frontmatter + broken link)..." + compose_exec -w /app -e PYTHONPATH=services \ + elemes python scripts/validate_docs.py + ;; *) echo "💡 Cara Penggunaan elemes.sh:" echo " ./elemes.sh init # Inisialisasi konfigurasi, folder, & template .env" @@ -446,7 +485,13 @@ dbrestore) echo " ./elemes.sh teacher # Buat/update akun guru (upsert, prompt nama & token)" echo " ./elemes.sh dbbackup # Backup database → backups/elemes_.sql" echo " ./elemes.sh dbrestore # Restore backup terbaru dari backups/" - echo " ./elemes.sh test # Jalankan unit & API test sub-home (tanpa DB)" + echo " ./elemes.sh test # Jalankan full test suite (alias ke test-all)" + echo " ./elemes.sh test-unit # Unit test saja (cepat, no DB)" + echo " ./elemes.sh test-integration # Integration test (butuh PostgreSQL test DB)" + echo " ./elemes.sh test-all # Full test suite (CI gate)" + echo " ./elemes.sh test-smoke # Smoke test post-deploy (unit + sub-home subset)" + echo " ./elemes.sh test-worker # Compiler worker test suite" + echo " ./elemes.sh docs-validate # Validasi frontmatter & broken link di docs/*.md" echo " ./elemes.sh loadtest # Menjalankan utilitas simulasi Load Test (Locust)" ;; esac diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..ecfc574 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +markers = + unit: Pure unit tests — no DB, no network, fast (<10ms/test) + integration: Requires database/services (PostgreSQL, Flask test client, service deps) + e2e: End-to-end smoke tests (browser/headless or full-stack container) +addopts = -v --tb=short --strict-markers +testpaths = + services/tests diff --git a/test_elemes_sh.sh b/scripts/test_elemes_sh.sh similarity index 99% rename from test_elemes_sh.sh rename to scripts/test_elemes_sh.sh index abf3763..a83bd93 100755 --- a/test_elemes_sh.sh +++ b/scripts/test_elemes_sh.sh @@ -24,7 +24,7 @@ FAKEBIN="$TESTROOT/fakebin" mkdir -p "$WORKSPACE" "$FAKEBIN" touch "$PARENT_DIR/.env" # PARENT_DIR/.env (dibaca run_compose & db_init) -cp "$(dirname "$0")/elemes.sh" "$WORKSPACE/elemes.sh" +cp "$(dirname "$0")/../elemes.sh" "$WORKSPACE/elemes.sh" export FAKE_PROJECT="$PROJECT_NAME" # --- Fake podman-compose: catat semua panggilan, jawab config/exec/restart ---- diff --git a/services/tests/test_auth_routes.py b/services/tests/test_auth_routes.py index 37de3e4..8af0a6f 100644 --- a/services/tests/test_auth_routes.py +++ b/services/tests/test_auth_routes.py @@ -11,9 +11,12 @@ import pytest from services.tests.conftest import STUDENT_TOKEN, TEACHER_TOKEN -pytestmark = pytest.mark.skipif( - not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata" -) +pytestmark = [ + pytest.mark.skipif( + not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata" + ), + pytest.mark.integration, +] @pytest.fixture(autouse=True) diff --git a/services/tests/test_compile_sessions.py b/services/tests/test_compile_sessions.py index 8cc65e6..2b8e1e2 100644 --- a/services/tests/test_compile_sessions.py +++ b/services/tests/test_compile_sessions.py @@ -10,6 +10,7 @@ Di host tanpa flask, test di-skip otomatis (importorskip). import pytest +pytestmark = pytest.mark.unit # Skip seluruh modul bila flask tidak tersedia (host tanpa deps backend) pytest.importorskip("flask") diff --git a/services/tests/test_lesson_registry.py b/services/tests/test_lesson_registry.py index 6cd9224..828883d 100644 --- a/services/tests/test_lesson_registry.py +++ b/services/tests/test_lesson_registry.py @@ -7,6 +7,8 @@ import os import pytest +pytestmark = pytest.mark.integration + from services import lesson_service from services.lesson_registry import lesson_specs, sync_lesson_registry diff --git a/services/tests/test_lesson_service_embed.py b/services/tests/test_lesson_service_embed.py index 6d2889c..16e24a0 100644 --- a/services/tests/test_lesson_service_embed.py +++ b/services/tests/test_lesson_service_embed.py @@ -1,4 +1,5 @@ import pytest +pytestmark = pytest.mark.unit from services.lesson_service import _process_embed_embeds diff --git a/services/tests/test_lesson_service_quiz.py b/services/tests/test_lesson_service_quiz.py index 67f1fea..0e99bdb 100644 --- a/services/tests/test_lesson_service_quiz.py +++ b/services/tests/test_lesson_service_quiz.py @@ -1,5 +1,6 @@ import pytest +pytestmark = pytest.mark.unit from services.lesson_service import _parse_flashcards diff --git a/services/tests/test_models.py b/services/tests/test_models.py index 936d578..c3e0c2c 100644 --- a/services/tests/test_models.py +++ b/services/tests/test_models.py @@ -1,10 +1,14 @@ """Metadata model: nama tabel, constraint, relasi — tanpa butuh DB hidup.""" +import pytest + from sqlalchemy import ForeignKeyConstraint from services import models as _models # noqa: F401 (mendaftarkan metadata) from services.database import Base +pytestmark = pytest.mark.unit + def _table(name): return Base.metadata.tables[name] diff --git a/services/tests/test_progress_routes.py b/services/tests/test_progress_routes.py index e437f31..4ec6866 100644 --- a/services/tests/test_progress_routes.py +++ b/services/tests/test_progress_routes.py @@ -14,9 +14,12 @@ import pytest from services.tests.conftest import STUDENT_TOKEN, TEACHER_TOKEN -pytestmark = pytest.mark.skipif( - not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata" -) +pytestmark = [ + pytest.mark.skipif( + not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata" + ), + pytest.mark.integration, +] @pytest.fixture(autouse=True) diff --git a/services/tests/test_quiz_attempt_routes.py b/services/tests/test_quiz_attempt_routes.py index fa14834..4069860 100644 --- a/services/tests/test_quiz_attempt_routes.py +++ b/services/tests/test_quiz_attempt_routes.py @@ -23,9 +23,12 @@ from services.database import SessionLocal from services.models import QuizAttempt, StudentProgress from services.tests.conftest import STUDENT_TOKEN -pytestmark = pytest.mark.skipif( - not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata" -) +pytestmark = [ + pytest.mark.skipif( + not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata" + ), + pytest.mark.integration, +] LESSON = "hello_world" diff --git a/services/tests/test_repositories.py b/services/tests/test_repositories.py index 6a5ede8..7048c64 100644 --- a/services/tests/test_repositories.py +++ b/services/tests/test_repositories.py @@ -30,7 +30,13 @@ from services.tests.conftest import STUDENT2_TOKEN, STUDENT_TOKEN, TEACHER_TOKEN DB_REQUIRED = os.environ.get("DATABASE_URL", "").strip() -pytestmark = pytest.mark.skipif(not DB_REQUIRED, reason="butuh DATABASE_URL (PostgreSQL nyata)") +pytestmark = [ + pytest.mark.skipif( + not DB_REQUIRED, + reason="butuh DATABASE_URL (PostgreSQL nyata)", + ), + pytest.mark.integration, +] FIXTURES = Path(__file__).parent / "fixtures" UUID_1 = "7eab651c-5eb1-4eb8-8fd2-17fd77aec6df" diff --git a/services/tests/test_student_management_routes.py b/services/tests/test_student_management_routes.py index 1bd450e..e2893a7 100644 --- a/services/tests/test_student_management_routes.py +++ b/services/tests/test_student_management_routes.py @@ -22,7 +22,13 @@ from services.tests.conftest import STUDENT_TOKEN, TEACHER_TOKEN DB_REQUIRED = os.environ.get("DATABASE_URL", "").strip() -pytestmark = pytest.mark.skipif(not DB_REQUIRED, reason="butuh DATABASE_URL (PostgreSQL nyata)") +pytestmark = [ + pytest.mark.skipif( + not DB_REQUIRED, + reason="butuh DATABASE_URL (PostgreSQL nyata)", + ), + pytest.mark.integration, +] FIXTURES = Path(__file__).parent / "fixtures" diff --git a/services/tests/test_student_roundtrip.py b/services/tests/test_student_roundtrip.py index 84840ec..5e8507c 100644 --- a/services/tests/test_student_roundtrip.py +++ b/services/tests/test_student_roundtrip.py @@ -11,6 +11,7 @@ from pathlib import Path import pytest +pytestmark = pytest.mark.unit from services.progress_status import ( ParsedProgress, format_progress_status, diff --git a/services/tests/test_sub_home.py b/services/tests/test_sub_home.py index a3aca56..b14f81c 100644 --- a/services/tests/test_sub_home.py +++ b/services/tests/test_sub_home.py @@ -16,6 +16,7 @@ import time import pytest +pytestmark = pytest.mark.unit from services import lesson_service from services.lesson_service import ( _read_md_cached, diff --git a/services/tests/test_sub_home_api.py b/services/tests/test_sub_home_api.py index d7a0d80..a9d8178 100644 --- a/services/tests/test_sub_home_api.py +++ b/services/tests/test_sub_home_api.py @@ -13,6 +13,7 @@ import os import pytest +pytestmark = pytest.mark.integration from services import lesson_service diff --git a/services/tests/test_teacher_bootstrap.py b/services/tests/test_teacher_bootstrap.py index 1bee672..08a4ab7 100644 --- a/services/tests/test_teacher_bootstrap.py +++ b/services/tests/test_teacher_bootstrap.py @@ -96,6 +96,7 @@ def _counts(db): @needs_db +@pytest.mark.integration class TestTeacherBootstrapDB: """Skenario database — hanya jalan saat DATABASE_URL tersedia.""" diff --git a/services/tests/test_token_hashing.py b/services/tests/test_token_hashing.py index e16f2c4..e5a5a86 100644 --- a/services/tests/test_token_hashing.py +++ b/services/tests/test_token_hashing.py @@ -1,7 +1,11 @@ """Unit test hashing token — murni, tanpa DB.""" +import pytest + from services.token_hashing import hash_token, pepper_set +pytestmark = pytest.mark.unit + def test_hash_deterministic(monkeypatch): monkeypatch.setenv("TOKEN_PEPPER", "pepper-uji") diff --git a/services/tests/test_token_service_contract.py b/services/tests/test_token_service_contract.py index 0022467..108b725 100644 --- a/services/tests/test_token_service_contract.py +++ b/services/tests/test_token_service_contract.py @@ -17,9 +17,12 @@ import pytest from services import token_service as ts from services.tests.conftest import STUDENT2_TOKEN, STUDENT_TOKEN, TEACHER_TOKEN -pytestmark = pytest.mark.skipif( - not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata" -) +pytestmark = [ + pytest.mark.skipif( + not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata" + ), + pytest.mark.integration, +] @pytest.fixture(autouse=True)