Dev #14

Merged
w-vandal merged 2 commits from dev into main 2026-09-07 14:52:22 +00:00
13 changed files with 422 additions and 3 deletions
Showing only changes of commit f7a50e5afe - Show all commits
+2
View File
@@ -34,6 +34,8 @@ from .games.create_game import create_game
from .games.update_game_name import update_game_name
from .games.delete_game import delete_game
from .games.move_game import move_game
from .games.get_xapi_settings import get_xapi_settings
from .games.set_xapi_settings import set_xapi_settings
from .games.game_type_catalog import (
ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE,
get_onboarding_type, get_onboarding_type_raw, set_onboarding_type,
+23
View File
@@ -0,0 +1,23 @@
from ..connection import connect
def get_xapi_settings(slug):
"""Réglages xAPI de CE jeu (voir set_xapi_settings.py — stockés dans
_meta, même convention que 'name'/'onboarding_type', voir
db/games/game_type_catalog.py) : URL du LRS (Learning Record Store)
+ identifiants, saisis une seule fois par le créateur du jeu (voir
routes/publish/xapi_settings.py) plutôt que négociés à chaque
lancement (jamais le protocole cmi5 complet, voir publish/
build_scorm_package.py::build_scorm_zip). Chaînes vides si non
configuré — `endpoint` vide signifie "xAPI désactivé pour ce jeu"."""
conn = connect(slug)
rows = conn.execute(
"SELECT key, value FROM _meta WHERE key IN ('xapi_lrs_endpoint', 'xapi_lrs_login', 'xapi_lrs_password')"
).fetchall()
conn.close()
values = {row["key"]: row["value"] for row in rows}
return {
"endpoint": values.get("xapi_lrs_endpoint") or "",
"login": values.get("xapi_lrs_login") or "",
"password": values.get("xapi_lrs_password") or "",
}
+24
View File
@@ -0,0 +1,24 @@
from ..connection import connect
def set_xapi_settings(slug, endpoint, login, password=None):
"""Enregistre les réglages xAPI de CE jeu (voir get_xapi_settings.py).
`password=None` (champ laissé vide côté formulaire, voir
routes/publish/xapi_settings.py) laisse le mot de passe déjà
enregistré INCHANGÉ — jamais écrasé par une chaîne vide, pour ne pas
obliger à le retaper à chaque modification de l'URL/du login, et pour
que la route GET puisse ne jamais renvoyer sa valeur au navigateur
(juste un booléen "déjà configuré")."""
conn = connect(slug)
conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_endpoint', ?)", (endpoint or "",)
)
conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_login', ?)", (login or "",)
)
if password is not None:
conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_password', ?)", (password,)
)
conn.commit()
conn.close()
+26
View File
@@ -238,6 +238,26 @@ def build_scorm_zip(slug):
# seule personne à la fois sur son poste, comme /game/<slug>/play —
# jamais d'état "par joueur" à distinguer hors ligne.
payload = screens.full_game_payload(slug)
# xAPI (voir db/games/get_xapi_settings.py, routes/publish/
# xapi_settings.py) : bolt-on volontairement simple, JAMAIS le
# protocole cmi5 complet (pas de négociation de jeton au lancement) —
# les identifiants du LRS sont saisis une fois par le créateur du jeu
# et embarqués tels quels dans le paquet exporté, lu par
# static/js/play/offline/xapi-client.js. `endpoint` vide (réglage
# jamais renseigné) désactive entièrement xAPI pour ce jeu : le
# SCORM classique (scorm-api.js) continue de fonctionner à l'identique
# dans tous les cas.
xapi_settings = db.get_xapi_settings(slug)
xapi_config = None
if xapi_settings["endpoint"]:
xapi_config = {
"endpoint": xapi_settings["endpoint"],
"login": xapi_settings["login"],
"password": xapi_settings["password"],
"activity_id": f"urn:forge-engine:game:{slug}",
"activity_name": game["name"],
}
# gameData.icon_data_uris (voir forgeRenderIcone,
# static/js/play/offline/render-special-widgets.js) : ajouté au
# payload AVANT le rendu pour qu'il fasse partie du même payload_json
@@ -253,6 +273,12 @@ def build_scorm_zip(slug):
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
offline_mode=True, asset_url=lambda filename: "static/" + filename,
scorm_api_wrapper_url="static/js/play/offline/scorm-api.js",
# "</" -> "<\\/" : endpoint/login sont saisis librement par le
# créateur du jeu (voir routes/publish/xapi_settings.py) — sans
# cet échappement, une valeur contenant "</script>" refermerait
# prématurément la balise <script> de templates/play.html qui
# embarque ce JSON (json.dumps n'échappe jamais "<"/">"/"&").
xapi_config_json=json.dumps(xapi_config).replace("</", "<\\/"),
)
html = _relativize_absolute_urls(html, slug)
html = _inline_icon_urls(html, payload["icon_data_uris"])
+1
View File
@@ -1 +1,2 @@
from . import export_scorm
from . import xapi_settings
+33
View File
@@ -0,0 +1,33 @@
from flask import jsonify, request
import db
from core.flask_app import app
@app.route("/game/<slug>/xapi-settings", methods=["GET"])
def xapi_settings_get(slug):
"""Réglages xAPI de ce jeu (voir db/games/get_xapi_settings.py) —
jamais le mot de passe en clair, juste `password_set` (voir la
modale d'export, templates/base.html, qui pré-remplit endpoint/login
mais laisse toujours le champ mot de passe vide)."""
settings = db.get_xapi_settings(slug)
return jsonify({
"endpoint": settings["endpoint"],
"login": settings["login"],
"password_set": bool(settings["password"]),
})
@app.route("/game/<slug>/xapi-settings", methods=["POST"])
def xapi_settings_set(slug):
"""Enregistre les réglages xAPI de ce jeu — voir
db/games/set_xapi_settings.py : un mot de passe absent/vide dans le
corps JSON laisse celui déjà enregistré inchangé plutôt que de
l'effacer."""
body = request.get_json(silent=True) or {}
endpoint = (body.get("endpoint") or "").strip()
login = (body.get("login") or "").strip()
password = body.get("password") or None
db.set_xapi_settings(slug, endpoint, login, password)
return jsonify({"ok": True})
@@ -117,6 +117,11 @@ function forgeSyncQuizScoreToScorm() {
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
gameData.scoring.score = window.forgeQuizScore;
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : ce chemin
// quête/quiz contourne static/js/play/offline/apply-actions.js (aucune
// action de flow "Modifier un score" ici), donc jamais notifié sans cet
// appel direct — même bug que le commentaire ci-dessus pour SCORM.
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score);
}
// Une quête acceptée = la partie a commencé, même sans le moindre point
@@ -135,6 +140,7 @@ function forgeSyncAllQuestsCompletionToScorm() {
if (!quests.length || !quests.every(function (q) { return q.status === 'terminee'; })) return;
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
gameData.scoring.status = 'termine';
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged('termine');
}
function forgeAddQuizScore(amount) {
+5
View File
@@ -62,6 +62,10 @@ function forgeApplyScoreActionOffline(gameData, action) {
return false;
}
gameData.scoring.score = newValue;
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : no-op si
// le fichier n'est pas chargé (tests Node via module.exports ci-dessous,
// ou réglage xAPI jamais configuré pour ce jeu).
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(newValue);
return true;
}
@@ -70,6 +74,7 @@ function forgeApplyStatusActionOffline(gameData, action) {
if (!status || FORGE_SCORE_STATUS_KEYS.indexOf(status) === -1) return false;
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
gameData.scoring.status = status;
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(status);
return true;
}
+122
View File
@@ -0,0 +1,122 @@
// Export Web/SCORM — bolt-on xAPI (voir publish/build_scorm_package.py,
// db/games/get_xapi_settings.py) : EN PLUS du SCORM classique
// (scorm-api.js, jamais touché par ce fichier), envoie des statements
// xAPI directement au LRS configuré par le créateur du jeu. Chargé
// TOUJOURS dans le paquet exporté (même sans réglage xAPI — voir
// templates/play.html), mais ne fait RIEN tant que
// window.FORGE_XAPI_CONFIG est `null` (réglage jamais renseigné).
//
// Volontairement PAS le protocole cmi5 (pas de négociation de jeton au
// lancement, pas de découverte d'endpoint via l'URL du LMS) : l'URL du
// LRS et les identifiants sont saisis une fois pour toutes par le
// créateur (voir routes/publish/xapi_settings.py) et embarqués tels
// quels dans le paquet — un LRS quelconque suffit, jamais besoin que le
// LMS du client supporte cmi5 lui-même.
//
// Best-effort partout : un LRS injoignable/mal configuré ne doit JAMAIS
// bloquer ni ralentir la partie du joueur.
// Statut Forge -> verbe xAPI ADL standard, uniquement pour les statuts
// qui marquent une VRAIE fin de partie — "non_commence"/"en_cours" ne
// génèrent rien ici (bruit inutile pour un suivi volontairement léger,
// voir le plan).
var FORGE_TO_XAPI_VERB = {
termine: { id: "http://adlnet.gov/expapi/verbs/completed", display: "completed" },
reussi: { id: "http://adlnet.gov/expapi/verbs/passed", display: "passed" },
echoue: { id: "http://adlnet.gov/expapi/verbs/failed", display: "failed" },
};
var forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si le statut ne change pas réellement
// Identité de l'apprenant : réutilise l'API SCORM déjà trouvée par
// scorm-api.js (window.forgeScormApi, posée sur "load" — voir plus bas,
// notre propre handler "load" s'enregistre APRÈS le sien, donc after lui)
// si le paquet tourne dans un LMS ; sinon, un acteur anonyme générique.
function forgeXapiActor() {
try {
if (typeof forgeScormApi !== "undefined" && forgeScormApi) {
var name = forgeScormApi.LMSGetValue("cmi.core.student_name");
var id = forgeScormApi.LMSGetValue("cmi.core.student_id");
if (id) {
return { objectType: "Agent", name: name || id, account: { homePage: "urn:forge-engine", name: id } };
}
}
} catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ }
return { objectType: "Agent", name: "Apprenant", mbox: "mailto:anonymous@forge-engine.local" };
}
function forgeXapiObject() {
var config = window.FORGE_XAPI_CONFIG;
return {
objectType: "Activity",
id: config.activity_id,
definition: { name: { "fr-FR": config.activity_name } },
};
}
// verbId/verbDisplay : voir FORGE_TO_XAPI_VERB, ou directement les
// verbes "initialized"/"terminated" (posés une seule fois chacun, voir
// plus bas) — result : objet xAPI "result" optionnel (score/completion/
// success), fusionné tel quel dans le statement.
function forgeXapiSendStatement(verbId, verbDisplay, result) {
var config = window.FORGE_XAPI_CONFIG;
if (!config || !config.endpoint) return;
var statement = {
actor: forgeXapiActor(),
verb: { id: verbId, display: { "en-US": verbDisplay } },
object: forgeXapiObject(),
timestamp: new Date().toISOString(),
};
if (result) statement.result = result;
var endpoint = config.endpoint.replace(/\/+$/, "") + "/statements";
try {
fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Experience-API-Version": "1.0.3",
"Authorization": "Basic " + btoa(config.login + ":" + config.password),
},
body: JSON.stringify(statement),
}).catch(function (err) {
console.warn("xAPI: envoi du statement échoué (LRS injoignable ?)", err);
});
} catch (err) {
console.warn("xAPI: impossible d'envoyer le statement", err);
}
}
// Rappelées directement par static/js/play/offline/apply-actions.js après
// chaque mutation RÉELLE (jamais un sondage périodique, contrairement à
// scorm-api.js::forgeScormSyncScore) — un seul statement par changement.
function forgeXapiNotifyScoreChanged(newScore) {
if (!window.FORGE_XAPI_CONFIG) return;
forgeXapiSendStatement(
"http://adlnet.gov/expapi/verbs/scored", "scored",
{ score: { raw: newScore } }
);
}
function forgeXapiNotifyStatusChanged(newStatus) {
if (!window.FORGE_XAPI_CONFIG) return;
var verb = FORGE_TO_XAPI_VERB[newStatus];
if (!verb || newStatus === forgeXapiLastStatusSent) return;
forgeXapiLastStatusSent = newStatus;
forgeXapiSendStatement(verb.id, verb.display, {
completion: true,
success: newStatus !== "echoue",
});
}
if (typeof window !== "undefined") {
window.addEventListener("load", function () {
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/initialized", "initialized");
});
window.addEventListener("beforeunload", function () {
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/terminated", "terminated");
});
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB };
}
+83 -3
View File
@@ -114,14 +114,44 @@
<div class="box">
<h2 class="title is-4">Exporter « {{ game.name }} » en Web/SCORM</h2>
<p class="hint mb-4">Construit un paquet SCORM 1.2 (imsmanifest.xml + page web autonome, aucun serveur requis) : importe le .zip obtenu directement dans ton LMS (Moodle...) — le score et le statut de la partie remontent automatiquement.</p>
<div id="scormProgress">
<!-- Suivi xAPI (voir publish/build_scorm_package.py, routes/publish/
xapi_settings.py) : greffé sur le SCORM ci-dessus, jamais à sa
place — un réglage jamais renseigné (endpoint vide) désactive
entièrement xAPI pour ce jeu, le SCORM continue seul. Réglé
séparément du bouton d'export : ces valeurs sont déjà en base
au moment où on clique "Exporter", jamais retapées à chaque export. -->
<details class="propGroup mb-4" id="xapiSettingsGroup">
<summary>Suivi xAPI (optionnel)</summary>
<div class="propGroupBody">
<p class="help mb-2">Envoie en plus les résultats vers un LRS (Learning Record Store) externe, en parallèle du SCORM ci-dessus. Utilise une clé API dédiée à cet export, jamais tes identifiants LRS principaux.</p>
<div class="field">
<label class="label is-small">URL du LRS</label>
<div class="control"><input class="input is-small" type="text" id="xapiEndpoint" placeholder="https://mon-lrs.exemple.com/xAPI"></div>
</div>
<div class="field">
<label class="label is-small">Login</label>
<div class="control"><input class="input is-small" type="text" id="xapiLogin" autocomplete="off"></div>
</div>
<div class="field">
<label class="label is-small">Mot de passe</label>
<div class="control"><input class="input is-small" type="password" id="xapiPassword" autocomplete="new-password"></div>
<p class="help" id="xapiPasswordHint"></p>
</div>
<div class="field is-grouped" style="align-items:center;">
<div class="control"><button type="button" class="button is-small" onclick="window.forgeSaveXapiSettings()">Enregistrer</button></div>
<span class="hint" id="xapiSaveStatus"></span>
</div>
</div>
</details>
<div id="scormProgress" style="display:none;">
<p class="hint" id="scormStepText">Préparation du jeu...</p>
<div class="passwordStrengthBar" style="margin-top:10px;"><div class="passwordStrengthFill strong" id="scormProgressFill" style="width:100%; animation:forgePublishPulse 1.2s ease-in-out infinite;"></div></div>
</div>
<div id="scormError" class="notification is-danger" style="display:none;"></div>
<div id="scormDone" class="notification is-success" style="display:none;">Téléchargement lancé — importe directement le .zip dans ton LMS.</div>
<div class="field mt-4" style="display:flex; justify-content:flex-end;">
<div class="field mt-4" style="display:flex; justify-content:flex-end; gap:8px;">
<button type="button" class="button is-small" onclick="window.forgeCloseScormModal()">Fermer</button>
<button type="button" class="button primary is-small" id="scormExportButton" onclick="window.forgeStartScormExport()">Exporter (Web/SCORM)</button>
</div>
</div>
</div>
@@ -136,13 +166,63 @@
var steps = ["Préparation du jeu...", "Génération du paquet hors ligne...", "Écriture du manifeste SCORM..."];
var stepTimer = null;
var xapiEndpoint = document.getElementById('xapiEndpoint');
var xapiLogin = document.getElementById('xapiLogin');
var xapiPassword = document.getElementById('xapiPassword');
var xapiPasswordHint = document.getElementById('xapiPasswordHint');
var xapiSaveStatus = document.getElementById('xapiSaveStatus');
function forgeLoadXapiSettings() {
fetch("{{ url_for('xapi_settings_get', slug=game.slug) }}")
.then(function (r) { return r.json(); })
.then(function (json) {
xapiEndpoint.value = json.endpoint || '';
xapiLogin.value = json.login || '';
xapiPassword.value = '';
xapiPasswordHint.textContent = json.password_set
? 'Mot de passe déjà enregistré — laisse ce champ vide pour le garder.' : '';
});
}
// Réglages, jamais retapés à chaque export : sauvegardés indépendamment
// du bouton "Exporter" ci-dessous (voir routes/publish/xapi_settings.py).
window.forgeSaveXapiSettings = function () {
xapiSaveStatus.textContent = 'Enregistrement...';
fetch("{{ url_for('xapi_settings_set', slug=game.slug) }}", {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: xapiEndpoint.value.trim(), login: xapiLogin.value.trim(),
password: xapiPassword.value || null,
}),
})
.then(function (r) { return r.json(); })
.then(function (json) {
xapiSaveStatus.textContent = json.ok ? 'Enregistré ✓' : 'Échec de l\'enregistrement.';
forgeLoadXapiSettings();
})
.catch(function () { xapiSaveStatus.textContent = 'Échec de l\'enregistrement.'; });
};
// Ouvrir la modale ne lance PLUS l'export tout de suite : le temps que
// l'utilisateur renseigne (et surtout enregistre, bouton séparé
// ci-dessus) ses réglages xAPI serait sinon une course contre le fetch
// d'export, qui pourrait partir avec des réglages pas encore sauvegardés.
// L'export est déclenché explicitement via forgeStartScormExport().
window.forgeOpenScormModal = function () {
progressBox.style.display = 'none';
errorBox.style.display = 'none';
doneBox.style.display = 'none';
xapiSaveStatus.textContent = '';
forgeLoadXapiSettings();
modal.classList.add('is-active');
};
window.forgeStartScormExport = function () {
var stepIndex = 0;
stepText.textContent = steps[0];
progressBox.style.display = '';
errorBox.style.display = 'none';
doneBox.style.display = 'none';
modal.classList.add('is-active');
stepTimer = setInterval(function () {
stepIndex = (stepIndex + 1) % steps.length;
+7
View File
@@ -121,6 +121,12 @@
// sur static/js/play/offline/*.js (ci-dessous) au lieu d'un fetch().
window.FORGE_OFFLINE = true;
var FORGE_PLAY_URLS = {};
// xAPI (voir publish/build_scorm_package.py::build_scorm_zip,
// static/js/play/offline/xapi-client.js) : bolt-on greffé sur le SCORM
// classique ci-dessous (scorm_api_wrapper_url), jamais lié à lui — un
// paquet sans réglage LRS renvoie `null` ici, xapi-client.js ne fait
// alors rien.
window.FORGE_XAPI_CONFIG = {{ xapi_config_json|safe }};
{% else %}
var FORGE_PLAY_URLS = {
runtimePayload: {{ runtime_payload_url|tojson }},
@@ -146,6 +152,7 @@
<script src="{{ asset_url('js/play/offline/render-element.js') }}"></script>
<script src="{{ asset_url('js/play/offline/full-payload.js') }}"></script>
{% if scorm_api_wrapper_url %}<script src="{{ scorm_api_wrapper_url }}"></script>{% endif %}
<script src="{{ asset_url('js/play/offline/xapi-client.js') }}"></script>
{% endif %}
<script src="{{ asset_url('js/play/actions.js') }}"></script>
<script src="{{ asset_url('js/play/triggers.js') }}"></script>
+24
View File
@@ -8,6 +8,7 @@ plan — validation ADL SCORM Cloud/Moodle hors du champ de la suite
automatisée)."""
import zipfile
import db
from tests.test_auth import anon_client # noqa: F401
@@ -59,6 +60,29 @@ def test_export_scorm_manifest_lists_index_html_as_the_sco(client, game):
assert "adlcp:scormtype=\"sco\"" in manifest
def test_export_scorm_index_html_has_no_xapi_config_when_unconfigured(client, game):
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
html = zf.read("index.html").decode("utf-8")
assert "window.FORGE_XAPI_CONFIG = null;" in html
# Toujours chargé, même sans réglage (no-op tant que la config est null).
assert 'src="static/js/play/offline/xapi-client.js"' in html
def test_export_scorm_index_html_embeds_the_xapi_config_when_set(client, game):
db.set_xapi_settings(game, "https://lrs.example.com/xapi/", "monlogin", "monmotdepasse")
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
html = zf.read("index.html").decode("utf-8")
assert "window.FORGE_XAPI_CONFIG = {" in html
assert "https://lrs.example.com/xapi/" in html
assert "monlogin" in html
assert "monmotdepasse" in html
assert 'src="static/js/play/offline/xapi-client.js"' in html
def test_export_scorm_route_is_isolated_like_other_game_routes(anon_client):
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
aucune vérification supplémentaire écrite pour cette route."""
+66
View File
@@ -0,0 +1,66 @@
"""Réglages xAPI (bolt-on, voir db/games/get_xapi_settings.py,
routes/publish/xapi_settings.py) — le mot de passe ne doit JAMAIS
ressortir en clair (seulement un booléen `password_set`), et un POST
sans mot de passe ne doit pas écraser celui déjà enregistré."""
from tests.test_auth import anon_client # noqa: F401
def test_xapi_settings_get_defaults_to_empty(client, game):
resp = client.get(f"/game/{game}/xapi-settings")
assert resp.status_code == 200
body = resp.get_json()
assert body == {"endpoint": "", "login": "", "password_set": False}
def test_xapi_settings_post_then_get_round_trip(client, game):
resp = client.post(f"/game/{game}/xapi-settings", json={
"endpoint": "https://lrs.example.com/xapi/",
"login": "monlogin",
"password": "monmotdepasse",
})
assert resp.status_code == 200
assert resp.get_json() == {"ok": True}
resp = client.get(f"/game/{game}/xapi-settings")
body = resp.get_json()
assert body["endpoint"] == "https://lrs.example.com/xapi/"
assert body["login"] == "monlogin"
assert body["password_set"] is True
assert "password" not in body
def test_xapi_settings_post_with_blank_password_keeps_the_existing_one(client, game):
client.post(f"/game/{game}/xapi-settings", json={
"endpoint": "https://lrs.example.com/xapi/",
"login": "monlogin",
"password": "monmotdepasse",
})
resp = client.post(f"/game/{game}/xapi-settings", json={
"endpoint": "https://lrs.example.com/xapi/v2/",
"login": "monlogin",
"password": "",
})
assert resp.status_code == 200
import db
settings = db.get_xapi_settings(game)
assert settings["endpoint"] == "https://lrs.example.com/xapi/v2/"
assert settings["password"] == "monmotdepasse"
def test_xapi_settings_route_is_isolated_like_other_game_routes(anon_client):
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
aucune vérification supplémentaire écrite pour cette route."""
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
_register(anon_client, "xapisettingsisolation@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
try:
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/xapi-settings")
assert resp.status_code == 403
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/xapi-settings", json={})
assert resp.status_code == 403
finally:
_cleanup_project("xapisettingsisolation@example.com")