fix(docs/api): hilangkan sync berulang di api_reference + UI API Reference di /docs
- routes/docs.py: pakai current_app, bukan create_app() per request — tidak lagi memicu DB sync ulang di setiap hit /docs/api-reference (dead code route_modules/rule_map dihapus) - frontend: entry statis API Reference di sidebar /docs, fetch lazy /api/docs/api-reference, render method badge + auth + docstring - test: 4 test yatim di test_teacher_bootstrap diberi marker unit; Makefile test-stats kini menampilkan count per marker + orphan (harus 0) - docs_service: hapus dead code _read_md_cached, sederhanakan cache-check _parse_frontmatter (satu getmtime, kondisi jelas) - lesson_service: gabung pemanggilan bleach.clean dobel jadi satu dengan css_sanitizer opsional — hilangkan NoCssSanitizerWarning (7x) - conftest: fixture autouse _isolate_database lewati isolasi untuk test unit supaya make test-unit tetap jalan tanpa DB walau DATABASE_URL diset Verifikasi: unit 114 passed, full suite 239 passed (0 warnings) dengan Postgres test, svelte-check 0 error, vitest 114 passed, docs-validate ok.
This commit is contained in:
parent
429b23e9b8
commit
16031fa728
9
Makefile
9
Makefile
|
|
@ -72,8 +72,13 @@ test-list:
|
|||
## 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"
|
||||
@$(PYTEST) --collect-only -q -m unit 2>/dev/null | tail -1
|
||||
@echo "^ unit tests"
|
||||
@$(PYTEST) --collect-only -q -m integration 2>/dev/null | grep -c "test" || echo "0"
|
||||
@$(PYTEST) --collect-only -q -m integration 2>/dev/null | tail -1
|
||||
@echo "^ integration tests"
|
||||
@$(PYTEST) --collect-only -q -m e2e 2>/dev/null | tail -1
|
||||
@echo "^ e2e tests"
|
||||
@$(PYTEST) --collect-only -q -m "not unit and not integration and not e2e" 2>/dev/null | tail -1
|
||||
@echo "^ ORPHAN tests (tanpa marker) — harus 0, unit + integration == total"
|
||||
@$(PYTEST) --collect-only -q 2>/dev/null | tail -1
|
||||
@echo "^ total collected"
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -493,14 +493,20 @@ student_id;token;nama_siswa;hello_world;variabel
|
|||
|| `./elemes.sh test` | Full test suite (alias ke `test-all`) |
|
||||
|| `./elemes.sh test-unit` | Unit test saja (cepat, no DB) |
|
||||
|| `./elemes.sh test-integration` | Integration test (butuh PostgreSQL `elemes_test`) |
|
||||
|| `./elemes.sh test-all` | Full test suite (CI gate) |
|
||||
|| `./elemes.sh test-all` | Full test suite |
|
||||
|| `./elemes.sh test-smoke` | Smoke test post-deploy (unit + sub-home subset) |
|
||||
|| `./elemes.sh docs-validate` | Validasi frontmatter & broken link di `docs/*.md` |
|
||||
|
||||
> **Catatan testing (CI):** CI otomatis (GitHub Actions) **belum diaktifkan** —
|
||||
> solo dev, test dijalankan manual sebelum push via
|
||||
> `make test-unit && make test-integration` (atau `./elemes.sh test-all`).
|
||||
> Rencana diaktifkan lagi saat ada kontributor lain; file `ci.yml` versi lama
|
||||
> tetap tersimpan di git history (`git show a5aedf6:.github/workflows/ci.yml`).
|
||||
|
||||
## Dokumentasi & Referensi API
|
||||
|
||||
- **Docs Viewer**: Buka `http://localhost:3000/docs` untuk panduan teknis lengkap (arsitektur, backend, frontend, kuis, velxio, embed, dll) yang merender file `docs/*.md` secara dinamis.
|
||||
- **API Reference**: `http://localhost:3000/docs/api-reference` menampilkan daftar semua endpoint Flask dengan docstring, metode, path, dan requirement auth.
|
||||
- **API Reference**: Buka `/docs` lalu klik **API Reference** di sidebar (entry pertama) untuk melihat daftar semua endpoint Flask dengan docstring, metode, path, dan requirement auth.
|
||||
- **Troubleshooting**: Buka `http://localhost:3000/help` untuk tutorial siswa; tautan ke Docs Viewer ada di sana.
|
||||
|
||||
## Database & Penyimpanan (PostgreSQL)
|
||||
|
|
|
|||
|
|
@ -17,3 +17,16 @@ export interface DocMeta {
|
|||
order: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
/** Satu entry dari GET /api/docs/api-reference. */
|
||||
export interface ApiReferenceEntry {
|
||||
method: string[];
|
||||
path: string;
|
||||
name: string;
|
||||
auth: boolean;
|
||||
doc: string;
|
||||
}
|
||||
|
||||
export interface ApiReferenceResponse {
|
||||
endpoints: ApiReferenceEntry[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { env } from '$env/dynamic/public';
|
||||
import { renderMath } from '$lib/actions/renderMath';
|
||||
import { tick } from 'svelte';
|
||||
import type { ApiReferenceEntry } from '$types/docs';
|
||||
|
||||
export let data: {
|
||||
docs: import('$types/docs').DocsIndexEntry[]
|
||||
|
|
@ -12,11 +13,16 @@
|
|||
let docContent: { title: string; html: string } | null = null;
|
||||
let docError = false;
|
||||
|
||||
let apiRefMode = false;
|
||||
let apiRefEntries: ApiReferenceEntry[] | null = null;
|
||||
let apiRefError = false;
|
||||
|
||||
$: filteredDocs = data.docs.filter(d =>
|
||||
d.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
async function selectDoc(doc: import('$types/docs').DocsIndexEntry) {
|
||||
apiRefMode = false;
|
||||
selectedDoc = doc;
|
||||
searchQuery = '';
|
||||
docError = false;
|
||||
|
|
@ -30,10 +36,29 @@
|
|||
docError = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectApiReference() {
|
||||
apiRefMode = true;
|
||||
selectedDoc = data.docs[0];
|
||||
searchQuery = '';
|
||||
docError = false;
|
||||
docContent = null;
|
||||
|
||||
if (apiRefEntries) return; // sudah pernah dimuat — reuse
|
||||
apiRefError = false;
|
||||
try {
|
||||
const res = await fetch('/api/docs/api-reference');
|
||||
if (!res.ok) throw new Error('Failed to load API reference');
|
||||
const payload = await res.json();
|
||||
apiRefEntries = payload.endpoints;
|
||||
} catch {
|
||||
apiRefError = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{selectedDoc?.title || 'Dokumentasi'} - Elemes LMS</title>
|
||||
<title>{apiRefMode ? 'API Reference' : selectedDoc?.title || 'Dokumentasi'} - Elemes LMS</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="docs-layout">
|
||||
|
|
@ -47,9 +72,18 @@
|
|||
/>
|
||||
</div>
|
||||
<nav class="docs-nav">
|
||||
<button
|
||||
class:selected={apiRefMode}
|
||||
class="api-ref-entry"
|
||||
onclick={selectApiReference}
|
||||
>
|
||||
<span class="doc-order">⚙</span>
|
||||
<span class="doc-title">API Reference</span>
|
||||
<span class="doc-category">endpoints</span>
|
||||
</button>
|
||||
{#each filteredDocs as doc (doc.slug)}
|
||||
<button
|
||||
class:selected={selectedDoc?.slug === doc.slug}
|
||||
class:selected={!apiRefMode && selectedDoc?.slug === doc.slug}
|
||||
onclick={() => selectDoc(doc)}
|
||||
>
|
||||
<span class="doc-order">#{doc.order}</span>
|
||||
|
|
@ -61,7 +95,40 @@
|
|||
</aside>
|
||||
|
||||
<main class="docs-content">
|
||||
{#if docError}
|
||||
{#if apiRefMode}
|
||||
{#if apiRefError}
|
||||
<p class="error">Gagal memuat API Reference.</p>
|
||||
{:else if !apiRefEntries}
|
||||
<p class="loading">Memuat API Reference…</p>
|
||||
{:else}
|
||||
<article class="api-ref">
|
||||
<h1>API Reference</h1>
|
||||
<p class="api-ref-subtitle">
|
||||
Daftar endpoint Flask yang terdaftar ({apiRefEntries.length} endpoint).
|
||||
</p>
|
||||
<div class="api-ref-list">
|
||||
{#each apiRefEntries as entry (entry.path + entry.method.join(','))}
|
||||
<section class="endpoint">
|
||||
<header class="endpoint-header">
|
||||
<div class="endpoint-methods">
|
||||
{#each entry.method as m}
|
||||
<span class="method method-{m.toLowerCase()}">{m}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<code class="endpoint-path">{entry.path}</code>
|
||||
<span class="endpoint-auth" class:auth-required={entry.auth}>
|
||||
{entry.auth ? 'Auth: guru' : 'Publik'}
|
||||
</span>
|
||||
</header>
|
||||
{#if entry.doc}
|
||||
<p class="endpoint-doc">{entry.doc}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
{:else if docError}
|
||||
<p class="error">Gagal memuat dokumen.</p>
|
||||
{:else if !docContent}
|
||||
<p class="loading">Pilih dokumen dari sidebar untuk memulai.</p>
|
||||
|
|
@ -134,6 +201,11 @@
|
|||
border-left: 3px solid var(--color-accent, #2563eb);
|
||||
}
|
||||
|
||||
.docs-nav .api-ref-entry {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.doc-order {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
|
|
@ -173,6 +245,90 @@
|
|||
color: var(--color-text-muted, #888);
|
||||
}
|
||||
|
||||
/* ── API Reference ─────────────────────────────────────────── */
|
||||
|
||||
.api-ref {
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.api-ref h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.api-ref-subtitle {
|
||||
color: var(--color-text-muted, #888);
|
||||
margin: 0.5rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.api-ref-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius, 6px);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.endpoint-methods {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.method {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.method-get { background: #e6f4ea; color: #137333; }
|
||||
.method-post { background: #e8f0fe; color: #1a73e8; }
|
||||
.method-put { background: #fef7e0; color: #b06000; }
|
||||
.method-patch { background: #f3e8fd; color: #7627bb; }
|
||||
.method-delete { background: #fce8e6; color: #c5221f; }
|
||||
|
||||
.endpoint-path {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.endpoint-auth {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #e6f4ea;
|
||||
color: #137333;
|
||||
}
|
||||
|
||||
.endpoint-auth.auth-required {
|
||||
background: #fce8e6;
|
||||
color: #c5221f;
|
||||
}
|
||||
|
||||
.endpoint-doc {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #555);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.docs-layout {
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Endpoints:
|
|||
|
||||
import re
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
from flask import Blueprint, current_app, jsonify
|
||||
|
||||
from services.docs_service import get_docs_index, get_doc_content
|
||||
|
||||
|
|
@ -40,33 +40,12 @@ def api_reference():
|
|||
them as structured API reference entries.
|
||||
|
||||
Each entry: {method, path, name, auth, doc}
|
||||
|
||||
Uses ``current_app`` (bukan ``create_app()``) agar tidak membuat instance
|
||||
app baru / memicu DB sync ulang pada setiap request.
|
||||
"""
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
entries = []
|
||||
|
||||
# Import all route modules to capture docstrings
|
||||
route_modules = [
|
||||
"routes.auth",
|
||||
"routes.compile",
|
||||
"routes.lessons",
|
||||
"routes.progress",
|
||||
"routes.help",
|
||||
"routes.student_management",
|
||||
"routes.quiz_attempts",
|
||||
"routes.docs",
|
||||
]
|
||||
|
||||
# Build a map of rule -> endpoint function for docstring extraction
|
||||
rule_map = {}
|
||||
for rule in app.url_map.iter_rules():
|
||||
if rule.endpoint != "static" and rule.endpoint != "api_reference":
|
||||
rule_map[rule.rule] = {
|
||||
"methods": sorted(rule.methods - {"HEAD", "OPTIONS"}),
|
||||
"endpoint": rule.endpoint,
|
||||
}
|
||||
|
||||
# Known auth requirements per route (derived from code inspection)
|
||||
auth_map = {
|
||||
"/login": False,
|
||||
|
|
@ -100,7 +79,7 @@ def api_reference():
|
|||
|
||||
# Extract from Flask view functions directly
|
||||
seen = set()
|
||||
for rule in app.url_map.iter_rules():
|
||||
for rule in current_app.url_map.iter_rules():
|
||||
if rule.endpoint == "static":
|
||||
continue
|
||||
path = rule.rule
|
||||
|
|
@ -111,7 +90,7 @@ def api_reference():
|
|||
methods = sorted(rule.methods - {"HEAD", "OPTIONS"})
|
||||
|
||||
# Try to get the view function's docstring
|
||||
view_func = app.view_functions.get(rule.endpoint)
|
||||
view_func = current_app.view_functions.get(rule.endpoint)
|
||||
doc = ""
|
||||
if view_func:
|
||||
doc = (view_func.__doc__ or "").strip()
|
||||
|
|
|
|||
|
|
@ -61,12 +61,12 @@ def _parse_frontmatter(filename):
|
|||
if not abs_path.startswith(_DOCS_DIR + os.sep) and abs_path != _DOCS_DIR:
|
||||
return None
|
||||
|
||||
current_mtime = os.path.getmtime(file_path) if os.path.exists(file_path) else None
|
||||
|
||||
with _file_cache_lock:
|
||||
cached = _file_cache.get(abs_path)
|
||||
if cached and cached.get("mtime") == os.path.getmtime(file_path) if os.path.exists(file_path) else False:
|
||||
cached_mtime = os.path.getmtime(file_path)
|
||||
if cached and cached.get("mtime") == cached_mtime:
|
||||
return cached["data"]
|
||||
if cached and current_mtime is not None and cached.get("mtime") == current_mtime:
|
||||
return cached["data"]
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
|
@ -78,7 +78,6 @@ def _parse_frontmatter(filename):
|
|||
print(f"Warning: Could not read {file_path}: {e}")
|
||||
return None
|
||||
|
||||
current_mtime = os.path.getmtime(file_path)
|
||||
|
||||
# Parse frontmatter
|
||||
title = None
|
||||
|
|
@ -134,35 +133,6 @@ def _parse_frontmatter(filename):
|
|||
return result
|
||||
|
||||
|
||||
def _read_md_cached(path):
|
||||
"""Read any markdown file with mtime-based caching (mirrors lesson_service pattern)."""
|
||||
if not os.path.exists(path):
|
||||
return ""
|
||||
try:
|
||||
current_mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
cached = _file_cache.get(path)
|
||||
return (cached["content"] if cached else "") or ""
|
||||
|
||||
with _file_cache_lock:
|
||||
cached = _file_cache.get(path)
|
||||
if cached and cached.get("mtime") == current_mtime:
|
||||
return cached["content"]
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except (OSError, PermissionError) as e:
|
||||
print(f"Warning: Could not read {path}: {e}")
|
||||
cached = _file_cache.get(path)
|
||||
return (cached["content"] if cached else "") or ""
|
||||
|
||||
with _file_cache_lock:
|
||||
_file_cache[path] = {"content": content, "mtime": current_mtime}
|
||||
|
||||
return content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docs index (list all docs files, sorted by order)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -640,25 +640,20 @@ def _process_flowchart_embeds(text):
|
|||
|
||||
def _sanitize_embed_html(html_text):
|
||||
"""Sanitize raw embed HTML: whitelist tags/attrs/styles + check iframe src domain."""
|
||||
cleaned = bleach.clean(
|
||||
html_text,
|
||||
tags=EMBED_ALLOWED_TAGS,
|
||||
attributes=EMBED_ALLOWED_ATTRS,
|
||||
strip=True,
|
||||
)
|
||||
# Optional CSS sanitization — requires tinycss2 (skip if not installed)
|
||||
# CSS sanitization requires tinycss2 — bila tidak ada, style dipertahankan
|
||||
# tanpa disanitasi (tags/attrs tetap di-strip). Jangan pernah pass
|
||||
# css_sanitizer=None bersamaan dengan atribut `style` — bleach mengeluarkan
|
||||
# NoCssSanitizerWarning.
|
||||
try:
|
||||
from bleach.css_sanitizer import CSSSanitizer
|
||||
css_sanitizer = CSSSanitizer(allowed_css_properties=EMBED_ALLOWED_STYLES)
|
||||
cleaned = bleach.clean(
|
||||
html_text,
|
||||
tags=EMBED_ALLOWED_TAGS,
|
||||
attributes=EMBED_ALLOWED_ATTRS,
|
||||
css_sanitizer=css_sanitizer,
|
||||
strip=True,
|
||||
)
|
||||
except ImportError:
|
||||
pass # tinycss2 missing — CSS styles left unsanitized but tags/attrs still stripped
|
||||
css_sanitizer = None
|
||||
|
||||
kwargs = dict(tags=EMBED_ALLOWED_TAGS, attributes=EMBED_ALLOWED_ATTRS, strip=True)
|
||||
if css_sanitizer is not None:
|
||||
kwargs["css_sanitizer"] = css_sanitizer
|
||||
cleaned = bleach.clean(html_text, **kwargs)
|
||||
# Check every iframe src: must be https + not blacklisted
|
||||
for match in re.finditer(r'<iframe[^>]+src="([^"]*)"', cleaned):
|
||||
src = match.group(1)
|
||||
|
|
|
|||
|
|
@ -78,14 +78,22 @@ def seed_demo_users():
|
|||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_database():
|
||||
def _isolate_database(request):
|
||||
"""Isolasi integration test: truncate semua tabel sebelum SETIAP test.
|
||||
|
||||
Test importer/lesson-registry/progress berbagi DATABASE_URL yang sama;
|
||||
tanpa reset, data sisa antar test saling mengotori (mis. total lessons
|
||||
bertambah). Contract test (backend CSV) tidak terpengaruh — SessionLocal
|
||||
None bila DATABASE_URL tidak diset.
|
||||
|
||||
Test bertanda `unit` tidak pernah menyentuh DB — lewati isolasi sepenuhnya
|
||||
supaya `make test-unit` tetap jalan cepat & tanpa DB walau DATABASE_URL
|
||||
diset di environment (atau server PostgreSQL sedang mati).
|
||||
"""
|
||||
if request.node.get_closest_marker("unit") is not None:
|
||||
yield
|
||||
return
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from services.database import SessionLocal
|
||||
|
|
|
|||
|
|
@ -28,21 +28,26 @@ CLI = REPO_ROOT / "scripts" / "bootstrap_teacher.py"
|
|||
|
||||
# ── unit: validasi (tanpa DB) ────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validation_empty_name():
|
||||
with pytest.raises(TeacherBootstrapError, match="Nama guru"):
|
||||
upsert_teacher(None, display_name=" ", raw_token="TOKEN_X")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validation_empty_token():
|
||||
with pytest.raises(TeacherBootstrapError, match="Token guru"):
|
||||
upsert_teacher(None, display_name="Pak Guru", raw_token="")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validation_name_too_long():
|
||||
with pytest.raises(TeacherBootstrapError, match="terlalu panjang"):
|
||||
upsert_teacher(None, display_name="G" * 256, raw_token="TOKEN_X")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cli_exit_2_without_database():
|
||||
"""Tanpa DATABASE_URL, CLI harus gagal dengan exit code 2 (bukan crash)."""
|
||||
env = os.environ.copy()
|
||||
|
|
|
|||
Loading…
Reference in New Issue