From c1939d0e232d505b3cde4cc36f4f115c8a9ea721 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Mon, 21 Sep 2026 16:59:31 +0200 Subject: [PATCH] hint after a passed Antritt; split Cypress setup into global, suite and local layers; if configured a pass closes the Antritt chain: the next exam cell shows "Positiv abgeschlossen" with a tooltip, and an add attempt shows pruefungNachBestandenerNote; 8 new specs with 55 tests: kettenregeln, terminregeln, scope, rollenmatrix, antrittrollen, nebenlaeufigkeit, UI ablehnung and navigation; WIP documenting testsuite caveats in readme files; WIP streamlining layers and removing redundant code/fallbacks; --- cypress.config.js | 23 +- package.json | 34 +- public/css/Cis4/Benotungstool.css | 12 + .../Cis/Benotungstool/Benotungstool.js | 29 +- .../Cis/Benotungstool/notenRules.js | 3 + system/phrasesupdate.php | 40 ++ tests/cypress/.env.example | 71 +-- .../e2e/specs/api/noten/noten.access.cy.js | 76 ++- .../specs/api/noten/noten.antrittrollen.cy.js | 70 +++ .../e2e/specs/api/noten/noten.bulk.cy.js | 86 ++-- .../e2e/specs/api/noten/noten.freigabe.cy.js | 72 ++- .../e2e/specs/api/noten/noten.frist.cy.js | 128 ++--- .../specs/api/noten/noten.kettenregeln.cy.js | 248 +++++++++ .../api/noten/noten.nebenlaeufigkeit.cy.js | 100 ++++ .../api/noten/noten.notenschluessel.cy.js | 32 +- .../api/noten/noten.notenvorschlag.cy.js | 48 +- .../api/noten/noten.pruefungsantritte.cy.js | 68 ++- .../api/noten/noten.pruefungstermin.cy.js | 56 +- .../specs/api/noten/noten.rollenmatrix.cy.js | 171 +++++++ .../e2e/specs/api/noten/noten.scope.cy.js | 480 ++++++++++++++++++ .../specs/api/noten/noten.terminregeln.cy.js | 299 +++++++++++ .../api/noten/noten.ueberschreiben.cy.js | 75 ++- .../benotungstool.ablehnung.cy.js | 139 +++++ .../benotungstool.freigabe.cy.js | 31 +- .../benotungstool/benotungstool.import.cy.js | 99 ++-- .../benotungstool.navigation.cy.js | 78 +++ .../benotungstool.notenvorschlag.cy.js | 28 +- .../benotungstool.pruefung.cy.js | 84 +-- .../benotungstool/benotungstool.punkte.cy.js | 93 ++-- tests/cypress/e2e/specs/unit/notenRules.cy.js | 56 +- tests/cypress/local/.env.example | 25 + tests/cypress/local/eslint.config.js | 87 ++++ tests/cypress/local/notenProfiles.js | 163 ++++++ tests/cypress/local/package.json | 25 + tests/cypress/local/profileSwitch.js | 344 +++++++++++++ tests/cypress/local/readme_local.txt | 137 +++++ tests/cypress/{tasks => local}/sshTunnel.js | 5 +- tests/cypress/local/withTunnel.js | 79 +++ tests/cypress/profiles/noten.js | 93 ++-- tests/cypress/suites/noten.js | 69 +-- tests/cypress/suites/readme_benotungstool.txt | 158 ++---- tests/cypress/support/api/notenApi.js | 37 +- tests/cypress/support/commands.js | 7 +- .../cypress/support/helpers/notenAssistenz.js | 80 +-- tests/cypress/support/helpers/notenConfig.js | 86 ++-- tests/cypress/support/helpers/notenErrors.js | 6 +- tests/cypress/support/helpers/notenReset.js | 15 +- .../cypress/support/helpers/notenScenario.js | 10 +- .../cypress/support/helpers/notenTestData.js | 85 ++-- .../cypress/support/pages/benotungstool.po.js | 194 +++---- tests/cypress/tasks/db.js | 38 +- tests/cypress/tasks/notenDb.js | 50 +- tests/cypress/tasks/notenProfil.js | 344 ------------- tests/cypress/tools/checks/noten.js | 2 +- tests/cypress/tools/dbCheck.js | 18 +- 55 files changed, 3500 insertions(+), 1486 deletions(-) create mode 100644 tests/cypress/e2e/specs/api/noten/noten.antrittrollen.cy.js create mode 100644 tests/cypress/e2e/specs/api/noten/noten.kettenregeln.cy.js create mode 100644 tests/cypress/e2e/specs/api/noten/noten.nebenlaeufigkeit.cy.js create mode 100644 tests/cypress/e2e/specs/api/noten/noten.rollenmatrix.cy.js create mode 100644 tests/cypress/e2e/specs/api/noten/noten.scope.cy.js create mode 100644 tests/cypress/e2e/specs/api/noten/noten.terminregeln.cy.js create mode 100644 tests/cypress/e2e/specs/ui/benotungstool/benotungstool.ablehnung.cy.js create mode 100644 tests/cypress/e2e/specs/ui/benotungstool/benotungstool.navigation.cy.js create mode 100644 tests/cypress/local/.env.example create mode 100644 tests/cypress/local/eslint.config.js create mode 100644 tests/cypress/local/notenProfiles.js create mode 100644 tests/cypress/local/package.json create mode 100644 tests/cypress/local/profileSwitch.js create mode 100644 tests/cypress/local/readme_local.txt rename tests/cypress/{tasks => local}/sshTunnel.js (95%) create mode 100644 tests/cypress/local/withTunnel.js delete mode 100644 tests/cypress/tasks/notenProfil.js diff --git a/cypress.config.js b/cypress.config.js index c1ea3b933..d8fcac019 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -2,9 +2,6 @@ const { defineConfig } = require("cypress"); require("dotenv").config({ path: "tests/cypress/.env" }); -// The suite being run: its cy.task handlers and its Cypress.env() values. -const suite = require("./tests/cypress/suites/noten"); - module.exports = defineConfig({ // Cypress 15 deprecates Cypress.env() inside cy.request options, which the API helpers use. allowCypressEnv: true, @@ -28,29 +25,13 @@ module.exports = defineConfig({ const missing = ["BASE_URL", "USER_NAME", "USER_PASSWORD"].filter((key) => !process.env[key]); if (missing.length) throw new Error(`Missing in tests/cypress/.env: ${missing.join(", ")}`); - suite.registerTasks(on); - - // the suite can switch the instance to a configuration profile for the run - on("before:run", () => suite.beforeRun(config)); - - // the profile goes back first; the pool and the SSH forward close even if that fails - on("after:run", async () => { - try { - await suite.afterRun(config); - } finally { - await require("./tests/cypress/tasks/db").closeDb(); - } - }); - - return config; + // each suite registers its own tasks and Cypress.env() values + return require("./tests/cypress/suites/noten").setupNodeEvents(on, config); }, }, env: { - // HTTP Basic + LDAP user the API calls authenticate as. adminusername: process.env.USER_NAME, adminpassword: process.env.USER_PASSWORD, - - ...suite.env, }, }); diff --git a/package.json b/package.json index 938dc37e0..d5cc46d81 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,16 @@ { "scripts": { - "lint": "eslint tests/cypress cypress.config.js eslint.config.js", - "lint:fix": "eslint tests/cypress cypress.config.js eslint.config.js --fix", - "format": "prettier --write \"tests/cypress/**/*.{js,md}\"", - "format:check": "prettier --check \"tests/cypress/**/*.{js,md}\"", - "cy:open": "cypress open", - "cy:run": "cypress run", - "cy:noten": "cypress run --spec \"tests/cypress/e2e/specs/**/*.cy.js\"", - "cy:noten:api": "cypress run --spec \"tests/cypress/e2e/specs/{unit,api}/**/*.cy.js\"", - "cy:noten:ui": "cypress run --browser chrome --spec \"tests/cypress/e2e/specs/ui/**/*.cy.js\"", - "cy:noten:ui:watch": "cypress run --browser chrome --headed --no-exit --spec \"tests/cypress/e2e/specs/ui/**/*.cy.js\"", - "cy:noten:punkte-on": "cypress run --browser chrome --env NOTEN_PUNKTE_MODUS=on --spec \"tests/cypress/e2e/specs/**/*.cy.js\"", - "cy:noten:punkte-off": "cypress run --browser chrome --env NOTEN_PUNKTE_MODUS=off --spec \"tests/cypress/e2e/specs/**/*.cy.js\"", - "cy:noten:p0": "cypress run --spec \"tests/cypress/e2e/specs/unit/notenRules.cy.js,tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js,tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js,tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js\"", - "cy:noten:profile": "node tests/cypress/tools/notenProfile.js lauf", - "noten:profil": "node tests/cypress/tools/notenProfile.js", - "noten:check": "node tests/cypress/tools/dbCheck.js noten" + "noten:check": "node tests/cypress/tools/dbCheck.js noten", + "noten:api": "cypress run --spec \"tests/cypress/e2e/specs/{unit,api}/**/*.cy.js\"", + "noten:ui": "cypress run --browser chrome --spec \"tests/cypress/e2e/specs/ui/**/*.cy.js\"" }, + "//": [ + "pg: the suite resets and seeds the test database directly (tasks/db.js, tools/dbCheck.js)", + "dotenv: cypress.config.js and the suite read URL, accounts and DB access from the .env files" + ], "dependencies": { "cypress": "^15.21.1", "dotenv": "^17.4.2", - "pg": "^8.13.1", - "ssh2": "^1.17.0" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "eslint": "^10.10.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-cypress": "^7.0.2", - "globals": "^17.12.0", - "prettier": "^3.9.7" + "pg": "^8.13.1" } } diff --git a/public/css/Cis4/Benotungstool.css b/public/css/Cis4/Benotungstool.css index da778c2c3..973e8a386 100644 --- a/public/css/Cis4/Benotungstool.css +++ b/public/css/Cis4/Benotungstool.css @@ -95,6 +95,18 @@ white-space: nowrap; } +/* Reason instead of a button, for example, after passing a test */ +.pruefung-cell .pruefung-hint { + grid-column: 1 / -1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.8rem; + font-style: italic; + opacity: 0.7; + cursor: help; +} + .pruefung-cell .pruefung-datum { font-size: 0.75rem; white-space: nowrap; diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js index d13466040..60e2f1198 100644 --- a/public/js/components/Cis/Benotungstool/Benotungstool.js +++ b/public/js/components/Cis/Benotungstool/Benotungstool.js @@ -493,8 +493,9 @@ export const Benotungstool = { let count = 0 this.studenten?.forEach(s => { // Add one more column while this row can get one more Termin. The kommissionelle - // Prüfung is the last Termin, therefore it uses such a column too. - const frei = this.canAddPruefung(s) ? 1 : 0 + // Prüfung is the last Termin, therefore it uses such a column too. A row closed by + // a pass also gets the column: the cell names the reason. + const frei = (this.canAddPruefung(s) || this.showBestandenHint(s)) ? 1 : 0 const needed = (s.pruefungen?.length ?? 0) + frei if(needed > count) count = needed }) @@ -524,6 +525,11 @@ export const Benotungstool = { return NotenRules.canAddPruefung(student, this.config) }, + /** A pass closed the chain: the next exam cell names the reason. A credited row stays empty. */ + showBestandenHint(student) { + return !this.canAddPruefung(student) && NotenRules.isBestanden(student) && !student.verlauf?.angerechnet + }, + checkFreigabe(freigabedatum, benotungsdatum) { return NotenRules.checkFreigabe(freigabedatum, benotungsdatum) }, @@ -1650,7 +1656,9 @@ export const Benotungstool = { // An empty cell gets an add button only if one more Termin is possible and if the column // is after all exams that exist. The kommissionelle Prüfung needs no own column: the // server decides the role of the new exam, the cell then shows the K badge. - if(!this.canAddPruefung(data)) return '' + // A pass closes the chain. That cell shows the reason instead of the button. + const bestanden = this.showBestandenHint(data) + if(!this.canAddPruefung(data) && !bestanden) return '' if(antrittModus) { // only the next free Termin column shows an add button @@ -1661,6 +1669,14 @@ export const Benotungstool = { if((data.pruefungen ?? []).some(p => gleicherTag ? p.datum > field : p.datum >= field)) return '' } + if(bestanden) { + rowDiv.classList.add('ohne-aktion') + const hint = addSlot('pruefung-hint', this.$capitalize(this.$p.t('benotungstool/c4bestandenHint'))) + hint.title = this.$capitalize(this.$p.t('benotungstool/c4bestandenTooltip')) + hint.dataset.cy = 'pruefung-bestanden' + return rowDiv + } + addButton( this.$capitalize(this.$p.t('benotungstool/addPruefungButtonText')), null, @@ -2422,7 +2438,7 @@ export const Benotungstool = { /** * Meldet, warum diese Zeile keinen Antritt mehr bekommt. Die gesperrte kommissionelle - * Prüfung nennt einen anderen Grund als die erreichte Grenze. + * Prüfung und eine bestandene Note nennen einen anderen Grund als die erreichte Grenze. */ warnKeinAntritt(student, nachsatz) { if(student.verlauf?.kommPruefGesperrt) { @@ -2430,6 +2446,11 @@ export const Benotungstool = { return } + if(NotenRules.isBestanden(student)) { + this.$fhcAlert.alertWarning(this.$capitalize(this.$p.t('benotungstool/pruefungNachBestandenerNote', [student.uid]))) + return + } + this.$fhcAlert.alertWarning( this.$capitalize(this.$p.t('benotungstool/c4antritteAufgebraucht', [student.uid, student.hoechsterAntritt, nachsatz])) diff --git a/public/js/components/Cis/Benotungstool/notenRules.js b/public/js/components/Cis/Benotungstool/notenRules.js index ac1e7c8c1..f2e1a5525 100644 --- a/public/js/components/Cis/Benotungstool/notenRules.js +++ b/public/js/components/Cis/Benotungstool/notenRules.js @@ -58,6 +58,9 @@ export const canAddPruefung = (student, config) => { return !hatKommPruef && antrittCountStudent(student, config) < maxAntrittCount(config); }; +/** Tells you if a pass closed the chain. Only the server knows it; without a `verlauf` the answer is false. */ +export const isBestanden = (student) => Boolean(student?.verlauf?.bestanden); + /** * Tells you if the new exam also creates the course grade. The value comes from the `verlauf` and * not from `lv_note`, because `lv_note` contains RELEASED grades only. An entered grade that is diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index ab24f1aef..80d9b0602 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -64978,6 +64978,46 @@ I have been informed that I am under no obligation to consent to the transmissio ) ) ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'c4bestandenHint', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Positiv abgeschlossen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Passed', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'c4bestandenTooltip', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Die Note des letzten Antritts ist positiv. Eine weitere Prüfung ist nicht möglich.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The grade of the last attempt is a pass. No further exam is possible.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), // CIS4 GESAMTNOTENEINGABE ENDE ------------------------------------------------------------------------------------ ); diff --git a/tests/cypress/.env.example b/tests/cypress/.env.example index 8a5099b57..52402fbe7 100644 --- a/tests/cypress/.env.example +++ b/tests/cypress/.env.example @@ -1,74 +1,9 @@ # Copy to tests/cypress/.env (gitignored) and fill in. -# THIS FILE IS COMMITTED - never put a real password in it. +# Only the keys every suite shares. A suite keeps its own keys in tests/cypress/suites/.env. -# ---- required ------------------------------------------------------------------------------- - -# Your own instance, e.g. https://c3p0.dev.technikum-wien.at//fhcompletecis4 -# No default anywhere: empty fails the run rather than pointing it at a colleague's installation. +# The instance under test. No default anywhere: empty fails the run rather than pointing it at a colleague's installation. BASE_URL= -# HTTP Basic + LDAP user the API calls authenticate as. Use the LECTURER under test -# (e.g. demolektor1) - getBenotungstoolContext resolves that user's own Lehrveranstaltungen, -# and an admin account usually teaches nothing and would resolve none. +# HTTP Basic + LDAP user for Cypress.env("adminusername") / ("adminpassword"). USER_NAME= USER_PASSWORD= - -# ---- required: fixture reset ------------------------------------------------------------------ -# The suite resets its fixture over a direct database connection; there is no HTTP fallback, so -# the specs fail without it. TEST_ENV_PREFIX selects the _DB_* / _SSH_* names. -TEST_ENV_PREFIX=NOTEN - -NOTEN_DB_HOST=db.dev.technikum-wien.at -NOTEN_DB_PORT=5432 -NOTEN_DB_NAME= -NOTEN_DB_USER= -NOTEN_DB_PASSWORD= -NOTEN_DB_ALLOW_WRITES=true - -# ---- required from a workstation: SSH tunnel -------------------------------------------------- -# pg_hba.conf admits only the application server. NOTEN_DB_HOST above stays the REAL database - -# the tunnel rewrites the connection to 127.0.0.1 itself. -# -# Two things that are easy to get wrong: -# - the SSH host is usually NOT the web hostname -# - a key whose filename is not an OpenSSH default is NEVER tried automatically; name it here, -# or leave NOTEN_SSH_KEY empty to use your SSH agent -NOTEN_SSH_TUNNEL=true -NOTEN_SSH_HOST= -NOTEN_SSH_USER= -NOTEN_SSH_KEY= - -# ---- optional --------------------------------------------------------------------------------- - -# Semester and LV are discovered at runtime. Pin them only when discovery picks the wrong one. -NOTEN_SEM= -NOTEN_LV_ID= - -# Access-control specs: this API has no impersonation, so scoping needs a second real login. -# NOTEN_FOREIGN_LV_ID must be an LV this teacher does NOT teach. Unset -> those specs skip. -NOTEN_TEACHER_USER= -NOTEN_TEACHER_PASSWORD= -NOTEN_FOREIGN_LV_ID= - -# Configuration profiles (tests/cypress/profiles/noten.js) change application/config/noten.php and the -# define() flags in config/global.config.inc.php on the instance, over SSH, and restore them after the -# run. NOTEN_REMOTE_ROOT is the absolute path of the instance on its host. -# npm run cy:noten:profile every profile, then a report -# npm run noten:profil -- status the active profile -NOTEN_REMOTE_ROOT= -# Only if the instance runs on another host than the SSH tunnel above. With NOTEN_PROFIL_SSH_HOST set, -# every profile SSH value comes from NOTEN_PROFIL_SSH_*; an empty key uses the SSH agent (on Windows -# the OpenSSH agent service). Without it the profiles use NOTEN_SSH_*. -NOTEN_PROFIL_SSH_HOST= -NOTEN_PROFIL_SSH_USER= -NOTEN_PROFIL_SSH_KEY= -# Only for a test instance with a self-signed certificate: the Node side (profile check, lock test) -# rejects it otherwise. Never set this for anything else. -# NODE_TLS_REJECT_UNAUTHORIZED=0 -# a profile for every plain run; empty = the instance as deployed -NOTEN_PROFIL= - -# LDAP password of USER_NAME for the Freigabe specs. Empty -> USER_PASSWORD. -# A successful Freigabe sends the Notenfreigabe mail. Run the suite only on an instance whose mail goes to a -# debug mailbox (MAIL_DEBUG), as on the dev machines. -NOTEN_FREIGABE_PASSWORD= diff --git a/tests/cypress/e2e/specs/api/noten/noten.access.cy.js b/tests/cypress/e2e/specs/api/noten/noten.access.cy.js index 142501f5f..fde877deb 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.access.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.access.cy.js @@ -1,11 +1,11 @@ /** - * Access control (P2) - das Scoping von assertLvAccess. + * Access control - the scope of `assertLvAccess`. * - * Diese API kennt keine Impersonation, der Lektoren-Scope braucht daher echte Zugangsdaten: + * This API does not support impersonation. therefore the lecturer scope requires valid credentials: * NOTEN_TEACHER_USER / NOTEN_TEACHER_PASSWORD / NOTEN_FOREIGN_LV_ID. */ -import { notenApi } from "../../../../support/api/notenApi"; +import { notenApi, notenAuth } from "../../../../support/api/notenApi"; import { expectAuthError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors"; import { attemptDate, @@ -15,11 +15,11 @@ import { resetNotenState, seedBaseline, } from "../../../../support/helpers/notenTestData"; -import { skipWenn } from "../../../../support/helpers/notenConfig"; +import { skipIf } from "../../../../support/helpers/notenConfig"; import { assistenzAuth, - assistenzKontext, - assistenzLvMitLehreinheiten, + assistenzContext, + assistenzLvWithLehreinheiten, requireAssistenz, } from "../../../../support/helpers/notenAssistenz"; @@ -75,7 +75,7 @@ describe("Noten API - Zugriffsschutz", () => { describe("Scope der Lehrperson", () => { beforeEach(function () { - skipWenn( + skipIf( this, !teacherConfigured(), "Übersprungen: braucht NOTEN_TEACHER_USER / NOTEN_TEACHER_PASSWORD und NOTEN_FOREIGN_LV_ID " + @@ -125,25 +125,22 @@ describe("Noten API - Zugriffsschutz", () => { }); }); - // getLvForStudiengang ist NICHT abgedeckt: als Admin greift isBerechtigt('admin') und jeder - // Aufruf gelingt. Braucht denselben Nicht-Admin-Login wie die Lektorentests oben. - + // getLvForStudiengang is NOT covered: as an admin, isBerechtigt(‘admin’) applies and every + // call succeeds. Requires the same non-admin login as the reviewer tests above. describe("getBenotungstoolContext als Assistenz", () => { beforeEach(function () { requireAssistenz(this); cy.clearAllCookies(); }); - - // C1: Der Assistenz-Zweig lud früher als einziger das StudiengangModel. Der Test schützt ihn. + it("liefert die berechtigten Studiengänge", () => { - assistenzKontext(ctx.semKurzbz).then((kontext) => { - expect(kontext, "ein Semester mit Studiengängen der Assistenz").to.not.be.null; - expect(kontext.data.isAssistenz, "isAssistenz").to.be.true; - expect(kontext.data.studiengaenge, "studiengaenge").to.be.an("array").and.not.be.empty; + assistenzContext(ctx.semKurzbz).then((result) => { + expect(result, "ein Semester mit Studiengängen der Assistenz").to.not.be.null; + expect(result.data.isAssistenz, "isAssistenz").to.be.true; + expect(result.data.studiengaenge, "studiengaenge").to.be.an("array").and.not.be.empty; }); }); - - // W9: Die alte Route las das Semester als lv_id. + it("ignoriert eine lv_id, die nicht nur aus Ziffern besteht", () => { getAs(assistenzAuth(), "getBenotungstoolContext", { sem_kurzbz: ctx.semKurzbz, @@ -155,12 +152,12 @@ describe("Noten API - Zugriffsschutz", () => { }); }); - // C5: Eine leere oder nicht numerische lv_id weitete die Zugriffsprüfung auf alle LVs aus. + // An empty or non-numeric lv_id would extend the access check to all LVs. describe("Parameter der Zugriffsprüfung", () => { const s0 = () => ctx.students[0]; // saveStudentenNoten fehlt: Der Endpunkt prüft zuerst das Passwort und verschickt eine Mail. - const endpunkte = { + const endpoints = { getStudentenNoten: (lv, sem) => notenApi.getStudentenNoten(lv, sem), getLehreinheitenFuerLv: (lv, sem) => notenApi.getLehreinheitenFuerLv(lv, sem), getNoteByPunkte: (lv, sem) => notenApi.getNoteByPunkte(50, lv, sem), @@ -176,21 +173,21 @@ describe("Noten API - Zugriffsschutz", () => { lehreinheit_id: s0().lehreinheit_id, sem_kurzbz: sem, }), - // createPruefungen prüft uids vor der Zugriffsprüfung + // createPruefungen checks UIDs before performing the access check createPruefungen: (lv, sem) => notenApi.createPruefungen([{ uid: s0().uid }], attemptDate(ctx, 1), lv, sem), savePruefungenBulk: (lv, sem) => notenApi.savePruefungenBulk(lv, sem, []), }; - Object.entries(endpunkte).forEach(([name, aufruf]) => { + Object.entries(endpoints).forEach(([name, call]) => { [null, 0, "", "abc", "1abc", -1].forEach((lv) => { it(`${name} lehnt lv_id=${JSON.stringify(lv)} ab`, () => { - aufruf(lv, ctx.semKurzbz).then((response) => expectNotenError(response, "wrongParameters")); + call(lv, ctx.semKurzbz).then((response) => expectNotenError(response, "wrongParameters")); }); }); [null, "", " "].forEach((sem) => { it(`${name} lehnt sem_kurzbz=${JSON.stringify(sem)} ab`, () => { - aufruf(ctx.lvId, sem).then((response) => expectNotenError(response, "wrongParameters")); + call(ctx.lvId, sem).then((response) => expectNotenError(response, "wrongParameters")); }); }); }); @@ -201,25 +198,24 @@ describe("Noten API - Zugriffsschutz", () => { before(() => { requireDbReset(); - // Der alte Code überschrieb eine LV-Note einer anderen LV. Diese Zeile setzt kein Reset zurück. - const suche = (i) => + const search = (i) => i >= ctx.students.length ? cy.wrap(null, { log: false }) : cy - .task("noten:db:andereLvNoten", { + .task("noten:db:otherLvNoten", { lvId: ctx.lvId, semKurzbz: ctx.semKurzbz, studentUid: ctx.students[i].uid, }) - .then((anzahl) => (anzahl === 0 ? ctx.students[i] : suche(i + 1))); + .then((count) => (count === 0 ? ctx.students[i] : search(i + 1))); - suche(0).then((found) => { + search(0).then((found) => { student = found; }); }); it("ändert bei lv_id=null keine LV-Note", function () { - skipWenn(this, !student, "Übersprungen: jeder Studierende hat LV-Noten in anderen LVs."); + skipIf(this, !student, "Übersprungen: jeder Studierende hat LV-Noten in anderen LVs."); const g2 = ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ)); @@ -237,14 +233,14 @@ describe("Noten API - Zugriffsschutz", () => { }); }); - // W10: Der Endpunkt liefert alle Lehreinheiten der LV, für jede Rolle mit Zugriff auf die LV. + // The endpoint returns all course units for the course, for each role with access to the course. describe("getLehreinheitenFuerLv", () => { - const lehreinheitenDerLv = (lvId) => + const lehreinheitenOfLv = (lvId) => cy - .task("noten:db:lehreinheitenDerLv", { lvId, semKurzbz: ctx.semKurzbz }) + .task("noten:db:lehreinheitenOfLv", { lvId, semKurzbz: ctx.semKurzbz }) .then((ids) => ids.map(String).sort()); - // die Abfrage liefert eine Zeile je Gruppe, eine Lehreinheit kann also mehrfach vorkommen + // The query returns one row per group, so a course may appear multiple times const idsOf = (rows) => [...new Set(rows.map((r) => String(r.lehreinheit_id)))].sort(); before(() => requireDbReset()); @@ -268,7 +264,7 @@ describe("Noten API - Zugriffsschutz", () => { "studentcount", ); - lehreinheitenDerLv(ctx.lvId).then((ids) => { + lehreinheitenOfLv(ctx.lvId).then((ids) => { expect(idsOf(rows), "lehreinheit_id").to.deep.eq(ids); }); }); @@ -278,13 +274,13 @@ describe("Noten API - Zugriffsschutz", () => { requireAssistenz(this); cy.clearAllCookies(); - assistenzLvMitLehreinheiten(ctx.semKurzbz).then((ziel) => { - expect(ziel, "eine LV der Assistenz mit Lehreinheiten").to.not.be.null; + assistenzLvWithLehreinheiten(ctx.semKurzbz).then((target) => { + expect(target, "eine LV der Assistenz mit Lehreinheiten").to.not.be.null; - getAs(assistenzAuth(), "getLehreinheitenFuerLv", { lv_id: ziel.lvId, sem_kurzbz: ziel.sem }).then( + getAs(assistenzAuth(), "getLehreinheitenFuerLv", { lv_id: target.lvId, sem_kurzbz: target.sem }).then( (response) => { const rows = expectNotenSuccess(response, "getLehreinheitenFuerLv als Assistenz"); - expect(idsOf(rows), "lehreinheit_id").to.deep.eq(ziel.lehreinheiten); + expect(idsOf(rows), "lehreinheit_id").to.deep.eq(target.lehreinheiten); }, ); }); @@ -294,7 +290,7 @@ describe("Noten API - Zugriffsschutz", () => { cy.request({ method: "GET", url: "/index.ci.php/api/frontend/v1/Lehre/getLeForLv", - auth: { username: Cypress.env("adminusername"), password: Cypress.env("adminpassword") }, + auth: notenAuth(), failOnStatusCode: false, }).then((response) => { expect(response.status, "Status von Lehre/getLeForLv").to.not.eq(200); diff --git a/tests/cypress/e2e/specs/api/noten/noten.antrittrollen.cy.js b/tests/cypress/e2e/specs/api/noten/noten.antrittrollen.cy.js new file mode 100644 index 000000000..ca5e273e1 --- /dev/null +++ b/tests/cypress/e2e/specs/api/noten/noten.antrittrollen.cy.js @@ -0,0 +1,70 @@ +/** + * From which attempt the exam is kommissionell. + * + * The number comes from CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT, not from this test. Checks that + * the built chain follows it: role -> legacy type -> the `kommissionell` flag. + */ + +import { expectNotenSuccess } from "../../../../support/helpers/notenErrors"; +import { skipIf } from "../../../../support/helpers/notenConfig"; +import { attemptDate, loadNotenContext, requireDbReset } from "../../../../support/helpers/notenTestData"; +import { + addPruefung, + attemptsOfStudent, + givenBaseline, + readStateViaApi, +} from "../../../../support/helpers/notenScenario"; + +describe("Noten API - ab welchem Antritt kommissionell", () => { + let ctx; + let fromAntritt; // null means no Antritt is kommissionell + + before(() => { + loadNotenContext().then((context) => { + ctx = context; + fromAntritt = context.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT ?? null; + cy.log(`KOMMISSIONELL_AB_ANTRITT = ${fromAntritt} maxAntritte = ${context.maxAntritte}`); + }); + }); + + beforeEach(() => requireDbReset()); + + it("liefert eine Antrittsnummer, die zur Kette passt", function () { + skipIf(this, fromAntritt === null, "Übersprungen: kein Antritt ist kommissionell."); + + expect(fromAntritt, "die Nummer ist eine Zahl ab 1").to.be.a("number").and.to.be.at.least(1); + expect(fromAntritt, "sie liegt innerhalb der Kette").to.be.at.most(ctx.maxAntritte); + }); + + it("baut die Kette nach der konfigurierten Nummer", function () { + skipIf( + this, + ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF === false, + "Übersprungen: das Werkzeug darf den letzten Antritt nicht anlegen.", + ); + + const student = ctx.students[0]; + expect(student, "ein Student der LV").to.exist; + + // The base step produces step 1. A negative note keeps the chain open. + givenBaseline(ctx, student, { note: ctx.notes.negativ }); + + for (let nr = 2; nr <= ctx.maxAntritte; nr += 1) { + addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, nr - 1) }).then((response) => + expectNotenSuccess(response, `Antritt ${nr}`), + ); + } + + readStateViaApi(ctx).then((data) => { + const attempts = attemptsOfStudent(data, student.uid); + expect(attempts.length, "die Kette ist vollständig").to.eq(ctx.maxAntritte); + + attempts.forEach((termin, index) => { + const nr = index + 1; + const expected = fromAntritt !== null && nr >= fromAntritt; + + expect(termin.kommissionell, `Antritt ${nr} kommissionell?`).to.eq(expected); + }); + }); + }); +}); diff --git a/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js b/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js index e5bea18ea..cba4b59d8 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js @@ -1,14 +1,14 @@ /** - * Bulk- und Importpfade (P2). Dieselben Guards wie beim einzelnen Speichern, aber je Zeile - * gemeldet: HTTP 200 mit der Fehlermeldung in data[uid]. + * Bulk and import paths. Same guards as for individual saves, but reported per row + * : HTTP 200 with the error message in data[uid]. */ import { notenApi } from "../../../../support/api/notenApi"; import { expectBulkRowAccepted, expectBulkRowError, expectNotenSuccess } from "../../../../support/helpers/notenErrors"; import { - requireKonfiguration, - requireNotenModus, - requirePunkteModus, + requireConfig, + requireNotenMode, + requirePunkteMode, requireWiederholung, } from "../../../../support/helpers/notenConfig"; import { @@ -31,8 +31,8 @@ describe("Noten API - Sammelpfade", () => { }); }); - /** Führt den Studierenden bis an die Grenze. Die Baseline liefert Antritt 1. */ - const bisZurGrenze = (student) => { + /** Takes students to the limit. The baseline provides the starting point. */ + const bringToCap = (student) => { for (let i = 0; i < ctx.maxAntritte - 1; i += 1) { addPruefung(ctx, student, { note: ctx.notes.negativ, @@ -47,8 +47,8 @@ describe("Noten API - Sammelpfade", () => { describe("createPruefungen", () => { beforeEach(function () { requireWiederholung(this, ctx); - // ohne Anlage der kommissionellen Prüfung meldet die Grenze kommPruefNichtErlaubt - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true); + // If the commission audit is not attached, the boundary returns “commAuditNotAllowed” + requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true); }); it("lehnt nur die Zeile gegen eine §1-Regel ab und nimmt die übrigen an", () => { @@ -59,7 +59,7 @@ describe("Noten API - Sammelpfade", () => { seedBaseline(ctx, atCap); seedBaseline(ctx, fresh); - const datum = bisZurGrenze(atCap); + const datum = bringToCap(atCap); notenApi .createPruefungen( @@ -93,13 +93,13 @@ describe("Noten API - Sammelpfade", () => { beforeEach(function () { requireDbReset(); requireWiederholung(this, ctx); - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true); }); it("wendet die §1-Regeln je Zeile an", function () { - // im Punktemodus leitet der Endpunkt die Note aus den Punkten ab; die Regelprüfung - // deckt der punktebasierte Test unten ab - requireNotenModus(this, ctx); + // In Punkte mode, the endpoint derives the grade from the points; the rule check + // is covered by the points-based test below + requireNotenMode(this, ctx); const atCap = ctx.students[0]; const fresh = ctx.students[1]; @@ -108,7 +108,7 @@ describe("Noten API - Sammelpfade", () => { seedBaseline(ctx, atCap); seedBaseline(ctx, fresh); - const datum = bisZurGrenze(atCap); + const datum = bringToCap(atCap); notenApi .savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [ @@ -134,10 +134,10 @@ describe("Noten API - Sammelpfade", () => { }); }); - // Punktemodus: die Note kommt aus dem Notenschlüssel, die Zeile liefert nur Punkte. + // Point mode: The grade comes from the grading key; the row only provides points. describe("Punktemodus", () => { beforeEach(function () { - requirePunkteModus(this, ctx); + requirePunkteMode(this, ctx); }); it("wendet die Regeln je Zeile auf die abgeleitete Note an", () => { @@ -149,8 +149,8 @@ describe("Noten API - Sammelpfade", () => { seedBaseline(ctx, atCap); seedBaseline(ctx, fresh); - const datum = bisZurGrenze(atCap); - const zeile = (student) => ({ + const datum = bringToCap(atCap); + const bulkRow = (student) => ({ uid: student.uid, note: null, punkte, @@ -158,21 +158,25 @@ describe("Noten API - Sammelpfade", () => { lehreinheit_id: student.lehreinheit_id, }); - notenApi.savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [zeile(atCap), zeile(fresh)]).then((response) => { - const data = expectNotenSuccess(response, "savePruefungenBulk mit Punkten"); - expectBulkRowError(data, atCap.uid, "maxAntritteReached"); - expectBulkRowAccepted(data, fresh.uid); - }); + notenApi + .savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [bulkRow(atCap), bulkRow(fresh)]) + .then((response) => { + const data = expectNotenSuccess(response, "savePruefungenBulk mit Punkten"); + expectBulkRowError(data, atCap.uid, "maxAntritteReached"); + expectBulkRowAccepted(data, fresh.uid); + }); - notenApi.getNoteByPunkte(punkte, ctx.lvId, ctx.semKurzbz).then((antwort) => { - const erwartet = antwort.body.data; + notenApi.getNoteByPunkte(punkte, ctx.lvId, ctx.semKurzbz).then((punkteResponse) => { + const expectedNote = punkteResponse.body.data; readStateViaApi(ctx).then((data) => { - const neu = attemptsOfStudent(data, fresh.uid).find( + const newTermin = attemptsOfStudent(data, fresh.uid).find( (p) => String(p.datum).slice(0, 10) === datum, ); - expect(neu, "der neue Termin").to.exist; - expect(String(neu.note), "die aus den Punkten abgeleitete Note").to.eq(String(erwartet)); + expect(newTermin, "der neue Termin").to.exist; + expect(String(newTermin.note), "die aus den Punkten abgeleitete Note").to.eq( + String(expectedNote), + ); }); }); }); @@ -181,8 +185,8 @@ describe("Noten API - Sammelpfade", () => { describe("saveNotenvorschlagBulk", () => { it("schreibt für jede Zeile eine LV-Note", function () { - // im Punktemodus kommt die Note aus dem Notenschlüssel, siehe den Punktemodus-Block - requireNotenModus(this, ctx); + // In points mode, the grade comes from the grading key, see the score mode block + requireNotenMode(this, ctx); const [a, b] = ctx.students; @@ -194,7 +198,7 @@ describe("Noten API - Sammelpfade", () => { { uid: b.uid, note: ctx.gradeNotes[1], punkte: null }, ]) .then((response) => { - // nach uid geschlüsselt: je Zeile die LV-Note oder eine Fehlermeldung + // Sorted by uid: each line contains either the course grade or an error message const data = expectNotenSuccess(response, "saveNotenvorschlagBulk"); expectBulkRowAccepted(data, a.uid); expectBulkRowAccepted(data, b.uid); @@ -213,28 +217,28 @@ describe("Noten API - Sammelpfade", () => { describe("Punktemodus", () => { beforeEach(function () { - requirePunkteModus(this, ctx); + requirePunkteMode(this, ctx); }); it("überspringt eine Zeile ohne Punkte und schreibt die übrigen", () => { - const ohnePunkte = ctx.students[0]; - const mitPunkten = ctx.students[1]; + const withoutPunkte = ctx.students[0]; + const withPunkte = ctx.students[1]; resetNotenState(ctx); notenApi .saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [ - { uid: ohnePunkte.uid, note: null, punkte: null }, - { uid: mitPunkten.uid, note: null, punkte: 100 }, + { uid: withoutPunkte.uid, note: null, punkte: null }, + { uid: withPunkte.uid, note: null, punkte: 100 }, ]) .then((response) => { - // eine kaputte Zeile darf den ganzen Import nicht abbrechen + // A broken line should not cause the entire import to fail const data = expectNotenSuccess(response, "Notenimport mit Luecke"); - expectBulkRowError(data, ohnePunkte.uid, "c4punkteKeineNoteErmittelt"); - expectBulkRowAccepted(data, mitPunkten.uid); + expectBulkRowError(data, withoutPunkte.uid, "c4punkteKeineNoteErmittelt"); + expectBulkRowAccepted(data, withPunkte.uid); }); - readLvGesamtnoteViaDb(ctx, ohnePunkte.uid).then((row) => { + readLvGesamtnoteViaDb(ctx, withoutPunkte.uid).then((row) => { expect(row, "die übersprungene Zeile bleibt ungeschrieben").to.be.null; }); }); diff --git a/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js b/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js index 035f5beb6..bd8a1d09a 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js @@ -1,19 +1,17 @@ /** - * Notenfreigabe (P1, cases 12-14). - * - * Eine erfolgreiche Freigabe verschickt die Freigabemail, solange CIS_GESAMTNOTE_FREIGABEMAIL nicht false - * ist. Die Entwicklungsinstanzen stellen jede Mail in ein Debug-Postfach zu (MAIL_DEBUG), deshalb laufen - * die Tests mit der Mail. Die Suite sieht die Mail selbst nicht: sie prüft die Vorlagen und die Antwort. + * A successful release sends the release email, provided that CIS_GESAMTNOTE_FREIGABEMAIL is not false + *. The development instances deliver every email to a debug mailbox (MAIL_DEBUG), which is why + * the tests run with the email. The suite does not see the email itself; it checks the templates and the response. */ -import { notenApi } from "../../../../support/api/notenApi"; +import { notenApi, notenAuth } from "../../../../support/api/notenApi"; import { expectBulkRowAccepted, expectBulkRowError, expectNotenError, expectNotenSuccess, } from "../../../../support/helpers/notenErrors"; -import { requireKonfiguration, requireNotenModus } from "../../../../support/helpers/notenConfig"; +import { requireConfig, requireNotenMode } from "../../../../support/helpers/notenConfig"; import { attemptDate, baselineBenotungsdatum, @@ -33,7 +31,7 @@ import { verlaufOfStudent, } from "../../../../support/helpers/notenScenario"; -const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || Cypress.env("adminpassword"); +const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || notenAuth().password; /** The payload shape saveStudentenNoten expects per student (see the studlist builder). */ const notenPayload = (student, noteBezeichnung) => ({ @@ -56,8 +54,8 @@ describe("Noten API - Notenfreigabe", () => { describe("Passwortschutz", () => { it("lehnt eine Freigabe mit falschem Passwort ab und ändert nichts", function () { - // ohne Passwortpflicht gibt der Aufruf frei - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", true); + // Without a password requirement, the call grants access + requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", true); requireDbReset(); const student = ctx.students[0]; @@ -115,14 +113,14 @@ describe("Noten API - Notenfreigabe", () => { expect(grades.freigabedatum, "the note is now freigegeben").to.exist; }); - // C1: Die Freigabe brach früher vor dem Schreiben ab. + // The release used to abort before writing. readLvGesamtnoteViaDb(ctx, student.uid).then((row) => { expect(row.freigabedatum, "C1: die Freigabe schreibt freigabedatum").to.not.be.null; }); }); it("legt mit der Freigabe den ersten Antritt an", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true); const student = ctx.students[0]; @@ -133,7 +131,7 @@ describe("Noten API - Notenfreigabe", () => { .saveStudentenNoten(freigabePassword(), [notenPayload(student)], ctx.lvId, ctx.semKurzbz) .then((response) => { const entry = expectNotenSuccess(response, "saveStudentenNoten").find((r) => r.uid === student.uid); - // ohne den Verlauf in der Antwort zeigt die Tabelle den neuen Termin erst nach einem Reload + // Without the history in the response, the table won't display the new date until it is reloaded expect(entry.verlauf.pruefungen, "der erste Antritt kommt mit der Antwort zurück").to.have.length( 1, ); @@ -147,7 +145,7 @@ describe("Noten API - Notenfreigabe", () => { }); it("legt ohne CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME keinen Termin an", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false); + requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false); const student = ctx.students[0]; @@ -168,7 +166,7 @@ describe("Noten API - Notenfreigabe", () => { }); it("gibt ohne Passwort frei, wenn die Konfiguration keines verlangt", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", false); + requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", false); const student = ctx.students[1]; @@ -188,14 +186,14 @@ describe("Noten API - Notenfreigabe", () => { }); }); - // W7: Eine einzelne Wiederholung ist nicht Antritt 1. Die Freigabe überschreibt ihre Note nicht. + // A single repeat is not Antritt 1. The approval does not overwrite your grade. it("behält die Note einer einzelnen Wiederholung", () => { const student = ctx.students[1]; const g2 = ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ)); resetNotenState(ctx); seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: false, erstantritt: false }); - seedPruefung(ctx, student, { note: g2, datum: attemptDate(ctx, 1), typ: "Termin2" }); + seedPruefung(ctx, student, { note: g2, datum: attemptDate(ctx, 1), type: "Termin2" }); notenApi .saveStudentenNoten(freigabePassword(), [notenPayload(student)], ctx.lvId, ctx.semKurzbz) @@ -208,8 +206,8 @@ describe("Noten API - Notenfreigabe", () => { }); }); - // Das Datum eines Termins gehört der Person, die ihn eingetragen hat. Die Freigabe macht die - // Note verbindlich und verschiebt kein Datum. + // The date of an appointment belongs to the person who entered it. Approving it makes the + // grade binding and does not change the date. it("behält das Datum einer bestehenden Prüfung", () => { const student = ctx.students[2]; const pruefungsdatum = attemptDate(ctx, 2); @@ -221,8 +219,8 @@ describe("Noten API - Notenfreigabe", () => { erstantritt: false, benotungsdatum: baselineBenotungsdatum(ctx), }); - // Antritt 1 mit einem eigenen Datum; ohne Termin datiert die Freigabe auf das Benotungsdatum - seedPruefung(ctx, student, { note: ctx.notes.negativ, datum: pruefungsdatum, typ: "Termin1" }); + // Antritt 1 with a custom date. if no date is specified, the release date is set to the grading date + seedPruefung(ctx, student, { note: ctx.notes.negativ, datum: pruefungsdatum, type: "Termin1" }); notenApi .saveStudentenNoten(freigabePassword(), [notenPayload(student)], ctx.lvId, ctx.semKurzbz) @@ -284,11 +282,11 @@ describe("Noten API - Notenfreigabe", () => { }); }); - // Die Mail geht an ein Debug-Postfach, die Suite liest sie nicht. Sie prüft deshalb, was die Mail braucht: - // beide Vorlagen mit Text und eine Freigabe, die mit eingeschalteter Mail vollständig antwortet. + // The email is sent to a debug mailbox; the suite does not read it. It therefore checks what the email requires: + // both templates with text and an approval that responds in full when email is enabled. describe("Freigabemail", () => { beforeEach(function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABEMAIL", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABEMAIL", true); requireDbReset(); }); @@ -324,24 +322,24 @@ describe("Noten API - Notenfreigabe", () => { }); }); - // CIS_GESAMTNOTE_FREIGABE_FINAL: Kein Pfad ändert eine freigegebene Note. Die Tests säen die - // Freigabe, deshalb brauchen sie kein Passwort. + // CIS_GESAMTNOTE_FREIGABE_FINAL: No path changes a released grade. The tests trigger the + // release, so they do not need a password. describe("endgültige Freigabe", () => { beforeEach(function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", true); requireDbReset(); }); - const andereNote = () => ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ)); + const otherNote = () => ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ)); it("lehnt eine Übernahme nach der Freigabe ab", () => { const student = ctx.students[0]; - // ausdrücklich freigegeben: unter FINAL sät die Baseline sonst eine offene Note + // Explicitly released: otherwise, the baseline seeds an open note under “FINAL” givenBaseline(ctx, student, { freigegeben: true }); notenApi - .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, andereNote()) + .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, otherNote()) .then((response) => expectNotenError(response, "freigabeEndgueltig")); readLvGesamtnoteViaDb(ctx, student.uid).then((row) => { @@ -364,24 +362,24 @@ describe("Noten API - Notenfreigabe", () => { }); it("lehnt im Import nur die freigegebene Zeile ab", function () { - // im Punktemodus leitet der Import die Note aus den Punkten ab - requireNotenModus(this, ctx); + // Point mode: The grade comes from the grading key; the row only provides points. + requireNotenMode(this, ctx); - const [freigegeben, offen] = ctx.students; + const [freigegeben, open] = ctx.students; resetNotenState(ctx); seedBaseline(ctx, freigegeben, { freigegeben: true }); - seedBaseline(ctx, offen, { freigegeben: false }); + seedBaseline(ctx, open, { freigegeben: false }); notenApi .saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [ - { uid: freigegeben.uid, note: andereNote(), punkte: null }, - { uid: offen.uid, note: andereNote(), punkte: null }, + { uid: freigegeben.uid, note: otherNote(), punkte: null }, + { uid: open.uid, note: otherNote(), punkte: null }, ]) .then((response) => { const data = expectNotenSuccess(response, "saveNotenvorschlagBulk"); expectBulkRowError(data, freigegeben.uid, "freigabeEndgueltig"); - expectBulkRowAccepted(data, offen.uid); + expectBulkRowAccepted(data, open.uid); }); }); }); diff --git a/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js b/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js index 94941a6ea..3b913cf15 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js @@ -1,12 +1,12 @@ /** - * Prüfungsordnung §1 - Noteneintragungsfrist. + * Examination Regulations §1 - Grade Entry Deadline. * - * Zwei Fristen beantworten zwei Fragen: CIS_GESAMTNOTE_FRIST_EINGABE sperrt den Zeitpunkt der Eingabe, - * CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM das Datum der Prüfung. Die Eingabefrist greift in jedem Schreibpfad - * direkt nach der Zugriffsprüfung, ein abgelehnter Request schreibt also nichts. Die Meldung nennt die Frist - * und prüft damit ihre Ableitung aus NOTENEINTRAGUNGSFRIST_SS/WS. + * Two deadlines address two issues: CIS_GESAMTNOTE_FRIST_EINGABE restricts the time of entry, + * CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM restricts the exam date. The entry deadline applies in every write path + * immediately after the access check; a rejected request therefore does not write anything. The message specifies the deadline + * and uses it to verify its derivation from NOTENEINTRAGUNGSFRIST_SS/WS. * - * Die Seeder-Gruppe benotungstool_fixture_erweitert legt das Sommersemester an, in dem der Testbenutzer nach der Frist unterrichtet. + * The seeder group benotungstool_fixture_erweitert creates the summer semester in which the test user is notified after the deadline. */ import { notenApi } from "../../../../support/api/notenApi"; @@ -17,15 +17,15 @@ import { fristHasPassed, loadNotenContext, requireDbReset, - lehrsemesterMitAbgelaufenerFrist, + teachingSemesterWithExpiredFrist, } from "../../../../support/helpers/notenTestData"; import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario"; -import { requireKonfiguration, requireWiederholung } from "../../../../support/helpers/notenConfig"; +import { requireConfig, requireWiederholung } from "../../../../support/helpers/notenConfig"; describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => { let ctx; - // Semester samt Lehrveranstaltung, in dem der Benutzer unterrichtet und die Frist abgelaufen ist - const abgelaufen = { SS: null, WS: null }; + // Semester, including the course, in which the user teaches and the deadline has passed + const expired = { SS: null, WS: null }; const fristSS = () => ctx.cisConfig.NOTENEINTRAGUNGSFRIST_SS; const fristWS = () => ctx.cisConfig.NOTENEINTRAGUNGSFRIST_WS; @@ -39,49 +39,49 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => { `AUSNAHME_GILT=${ctx.cisConfig.CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT}`, ); - ["SS", "WS"].forEach((typ) => - lehrsemesterMitAbgelaufenerFrist(typ, fristSS(), fristWS()).then((paar) => { - abgelaufen[typ] = paar; - cy.log(`${typ} mit abgelaufener Frist: ${JSON.stringify(paar)}`); + ["SS", "WS"].forEach((type) => + teachingSemesterWithExpiredFrist(type, fristSS(), fristWS()).then((pair) => { + expired[type] = pair; + cy.log(`${type} mit abgelaufener Frist: ${JSON.stringify(pair)}`); }), ); }); }); - // ein fehlendes Semester ist ein Fixturefehler und kein übersprungener Test - const paarFuer = (typ) => { - expect(abgelaufen[typ], `ein ${typ} mit abgelaufener Frist, in dem der Testbenutzer unterrichtet`).to.not.be + // A missing semester is a fixture error, not a skipped test + const pairFor = (type) => { + expect(expired[type], `ein ${type} mit abgelaufener Frist, in dem der Testbenutzer unterrichtet`).to.not.be .null; - return abgelaufen[typ]; + return expired[type]; }; - // §7 und §11: Die kommissionelle Prüfung muss bis zur Frist STATTFINDEN. Das ist eine andere Frage als - // "darf jetzt noch eingetragen werden". + // §7 and §11: The commission review must TAKE PLACE by the deadline. That is a different question from + // “Can it still be registered now?” describe("Prüfungsdatum nach der Frist", () => { beforeEach(() => requireDbReset()); - // die Frist des laufenden Semesters liegt vor diesem Datum - const nachDerFrist = () => `${Number(ctx.semKurzbz.slice(2, 6)) + 1}-12-31`; + // The deadline for the current semester is before this date + const afterFrist = () => `${Number(ctx.semKurzbz.slice(2, 6)) + 1}-12-31`; it("lehnt einen Termin ab, der nach der Frist liegt", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM", true); const student = ctx.students[0]; givenBaseline(ctx, student); - addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: nachDerFrist() }).then((response) => + addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: afterFrist() }).then((response) => expectNotenError(response, "pruefungsdatumNachFrist"), ); }); it("nimmt einen Termin nach der Frist an, wenn das Prüfungsdatum an keine Frist gebunden ist", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM", false); + requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM", false); requireWiederholung(this, ctx); const student = ctx.students[0]; givenBaseline(ctx, student); - addPruefung(ctx, student, { note: ctx.notes.negativ, datum: nachDerFrist() }).then((response) => + addPruefung(ctx, student, { note: ctx.notes.negativ, datum: afterFrist() }).then((response) => expectNotenSuccess(response, "Termin nach der Frist"), ); }); @@ -101,35 +101,35 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => { describe("Eingabe nach der Frist", () => { const s0 = () => ctx.students[0]; - // Der Studierende gehört nicht zur Lehrveranstaltung des abgelaufenen Semesters. Greift die Eingabefrist - // nicht, lehnt die Teilnehmerprüfung ab, und der Request schreibt trotzdem nichts. - const schreibpfade = { - saveStudentPruefung: (paar) => + // The student is not enrolled in the course from the previous semester. If the submission deadline + // does not apply, the system rejects the student's attempt, and the request still does not write anything. + const writePaths = { + saveStudentPruefung: (pair) => notenApi.saveStudentPruefung({ student_uid: s0().uid, note: ctx.gradeNotes[0], punkte: null, datum: attemptDate(ctx, 1), - lva_id: paar.lvId, + lva_id: pair.lvId, lehreinheit_id: s0().lehreinheit_id, - sem_kurzbz: paar.semKurzbz, + sem_kurzbz: pair.semKurzbz, pruefung_id: null, }), - saveNotenvorschlag: (paar) => - notenApi.saveNotenvorschlag(paar.lvId, paar.semKurzbz, s0().uid, ctx.gradeNotes[0]), - saveNotenvorschlagBulk: (paar) => - notenApi.saveNotenvorschlagBulk(paar.lvId, paar.semKurzbz, [ + saveNotenvorschlag: (pair) => + notenApi.saveNotenvorschlag(pair.lvId, pair.semKurzbz, s0().uid, ctx.gradeNotes[0]), + saveNotenvorschlagBulk: (pair) => + notenApi.saveNotenvorschlagBulk(pair.lvId, pair.semKurzbz, [ { uid: s0().uid, note: ctx.gradeNotes[0], punkte: null }, ]), - createPruefungen: (paar) => + createPruefungen: (pair) => notenApi.createPruefungen( [{ uid: s0().uid, lehreinheit_id: s0().lehreinheit_id }], attemptDate(ctx, 1), - paar.lvId, - paar.semKurzbz, + pair.lvId, + pair.semKurzbz, ), - savePruefungenBulk: (paar) => - notenApi.savePruefungenBulk(paar.lvId, paar.semKurzbz, [ + savePruefungenBulk: (pair) => + notenApi.savePruefungenBulk(pair.lvId, pair.semKurzbz, [ { uid: s0().uid, note: ctx.gradeNotes[0], @@ -140,49 +140,49 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => { ]), }; - const meldetFrist = (response) => + const reportsFrist = (response) => ((response.body && response.body.errors) || []).some((e) => messageMatchesPhrase(e.message, "noteneintragungsfristVorbei"), ); - Object.entries(schreibpfade).forEach(([pfad, aufruf]) => { - ["SS", "WS"].forEach((typ) => { - it(`${pfad} lehnt eine Eingabe im ${typ} nach der Frist ab`, function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", true); - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", false); + Object.entries(writePaths).forEach(([writePath, call]) => { + ["SS", "WS"].forEach((type) => { + it(`${writePath} lehnt eine Eingabe im ${type} nach der Frist ab`, function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", false); - const paar = paarFuer(typ); + const pair = pairFor(type); expect( - fristHasPassed(paar.semKurzbz, fristSS(), fristWS()), - `die Frist von ${paar.semKurzbz} ist vorbei`, + fristHasPassed(pair.semKurzbz, fristSS(), fristWS()), + `die Frist von ${pair.semKurzbz} ist vorbei`, ).to.be.true; - aufruf(paar).then((response) => { + call(pair).then((response) => { expectNotenError(response, "noteneintragungsfristVorbei"); - const meldung = response.body.errors.map((e) => e.message).join(" | "); - expect(meldung, `die Frist, die der Server für ${paar.semKurzbz} ableitet`).to.include( - expectedFristString(paar.semKurzbz, fristSS(), fristWS()), + const errorMessage = response.body.errors.map((e) => e.message).join(" | "); + expect(errorMessage, `die Frist, die der Server für ${pair.semKurzbz} ableitet`).to.include( + expectedFristString(pair.semKurzbz, fristSS(), fristWS()), ); }); }); }); - // Die Ausnahme deckt die EINGABE ab, nicht das Prüfungsdatum. - it(`${pfad} lässt eine Ausnahmerolle nach der Frist eintragen`, function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", true); - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", true); + // The exception covers the INPUT DATE, not the exam date. + it(`${writePath} lässt eine Ausnahmerolle nach der Frist eintragen`, function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", true); - aufruf(paarFuer("SS")).then((response) => { - expect(meldetFrist(response), "die Eingabefrist greift nicht").to.be.false; + call(pairFor("SS")).then((response) => { + expect(reportsFrist(response), "die Eingabefrist greift nicht").to.be.false; }); }); - it(`${pfad} nimmt ohne Eingabefrist eine späte Eingabe an`, function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", false); + it(`${writePath} nimmt ohne Eingabefrist eine späte Eingabe an`, function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", false); - aufruf(paarFuer("SS")).then((response) => { - expect(meldetFrist(response), "die Eingabefrist greift nicht").to.be.false; + call(pairFor("SS")).then((response) => { + expect(reportsFrist(response), "die Eingabefrist greift nicht").to.be.false; }); }); }); diff --git a/tests/cypress/e2e/specs/api/noten/noten.kettenregeln.cy.js b/tests/cypress/e2e/specs/api/noten/noten.kettenregeln.cy.js new file mode 100644 index 000000000..3e196ef2c --- /dev/null +++ b/tests/cypress/e2e/specs/api/noten/noten.kettenregeln.cy.js @@ -0,0 +1,248 @@ +/** + * When a grade closes the attempt chain. + * + * negative -> repeat allowed + * positive, not final -> only with CIS_GESAMTNOTE_NOTENVERBESSERUNG + * final -> chain closed, always + * + * tbl_note.positiv alone is not the rule: 'entschuldigt' and 'Teilgenommen' carry it too. Only the + * grade of a Termin that uses an attempt decides. + */ + +import { notenApi } from "../../../../support/api/notenApi"; +import { + expectBulkRowAccepted, + expectBulkRowError, + expectNotenError, + expectNotenSuccess, +} from "../../../../support/helpers/notenErrors"; +import { + attemptDate, + loadNotenContext, + readLvGesamtnoteViaDb, + requireDbReset, + resetNotenState, + seedBaseline, +} from "../../../../support/helpers/notenTestData"; +import { + addPruefung, + attemptsOfStudent, + editPruefung, + givenBaseline, + readStateViaApi, + verlaufOfStudent, +} from "../../../../support/helpers/notenScenario"; +import { requireConfig, requireWiederholung, skipIf } from "../../../../support/helpers/notenConfig"; + +describe("Noten API - wann eine Note die Kette schliesst", () => { + let ctx; + + before(() => { + loadNotenContext().then((context) => { + ctx = context; + cy.log(`NOTEN_ABSCHLIESSEND = ${JSON.stringify(ctx.cisConfig.NOTEN_ABSCHLIESSEND)}`); + cy.log(`NOTENVERBESSERUNG = ${ctx.cisConfig.CIS_GESAMTNOTE_NOTENVERBESSERUNG}`); + cy.log(`VERBESSERUNG_BESSERE_GEWINNT = ${ctx.cisConfig.CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT}`); + cy.log(`notes.negativ=${ctx.notes.negativ} positiv=${ctx.notes.positiv} bestnote=${ctx.notes.bestnote}`); + }); + }); + + const studentFor = (index) => { + const student = ctx.students[index]; + expect(student, `student at index ${index}`).to.exist; + return student; + }; + + beforeEach(() => requireDbReset()); + + it("lässt die Kette nach einer negativen Note offen", function () { + requireWiederholung(this, ctx); + + const student = studentFor(0); + + givenBaseline(ctx, student, { note: ctx.notes.negativ }); + + addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) }) + .then((response) => expectNotenSuccess(response, "Wiederholung nach negativer Note")) + .then(() => readStateViaApi(ctx)) + .then((data) => { + const verlauf = verlaufOfStudent(data, student.uid); + expect(verlauf.bestanden, "eine negative Note ist kein Abschluss").to.be.false; + }); + }); + + it("schliesst die Kette nach einer abschliessenden Note", function () { + skipIf(this, ctx.notes.bestnote === null, "Übersprungen: keine Note aus NOTEN_ABSCHLIESSEND in der 1-5-Skala."); + + const student = studentFor(1); + + givenBaseline(ctx, student, { note: ctx.notes.bestnote }); + + readStateViaApi(ctx) + .then((data) => { + const verlauf = verlaufOfStudent(data, student.uid); + expect(verlauf.bestanden, "die Bestnote schliesst ab").to.be.true; + expect(verlauf.canAdd, "kein weiterer Termin nach der Bestnote").to.be.false; + }) + .then(() => addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) })) + .then((response) => expectNotenError(response, "pruefungNachBestandenerNote")); + }); + + // The top grade ALWAYS ends the chain, even when the improvement feature is enabled. + it("schliesst die Kette nach der Bestnote auch bei erlaubter Verbesserung", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true); + skipIf(this, ctx.notes.bestnote === null, "Übersprungen: keine Note aus NOTEN_ABSCHLIESSEND in der 1-5-Skala."); + + const student = studentFor(2); + + givenBaseline(ctx, student, { note: ctx.notes.bestnote }); + + addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) }).then((response) => + expectNotenError(response, "pruefungNachBestandenerNote"), + ); + }); + + describe("eine positive Note ausserhalb NOTEN_ABSCHLIESSEND", () => { + beforeEach(function () { + skipIf( + this, + ctx.notes.positiv === null, + "Übersprungen: keine positive Note ausserhalb NOTEN_ABSCHLIESSEND.", + ); + }); + + it("schliesst die Kette ohne Notenverbesserung", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", false); + + const student = studentFor(3); + + givenBaseline(ctx, student, { note: ctx.notes.positiv }); + + addPruefung(ctx, student, { note: ctx.notes.positiv, datum: attemptDate(ctx, 1) }).then((response) => + expectNotenError(response, "pruefungNachBestandenerNote"), + ); + }); + + it("lässt mit Notenverbesserung einen weiteren Antritt zu", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true); + + const student = studentFor(3); + + givenBaseline(ctx, student, { note: ctx.notes.positiv }); + + addPruefung(ctx, student, { note: ctx.notes.positiv, datum: attemptDate(ctx, 1) }).then((response) => + expectNotenSuccess(response, "Verbesserung nach positiver Note"), + ); + }); + + // An attempt to improve a grade must not lower the course grade if configured to do so. + it("behält bei einer schlechteren Verbesserung die bessere LV-Note", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT", true); + + const student = studentFor(4); + + givenBaseline(ctx, student, { note: ctx.notes.positiv }); + + // The improvement is less significant than the first attempt + addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) }) + .then((response) => expectNotenSuccess(response, "schlechtere Verbesserung")) + .then(() => readLvGesamtnoteViaDb(ctx, student.uid)) + .then((row) => { + expect(String(row.note), "die LV-Note behält die bessere Note").to.eq(String(ctx.notes.positiv)); + }); + + // The exam itself reflects the grade the student actually received + readStateViaApi(ctx).then((data) => { + const attempts = attemptsOfStudent(data, student.uid); + const latest = attempts[attempts.length - 1]; + expect(String(latest.note), "der Termin trägt die echte Note").to.eq(String(ctx.notes.negativ)); + }); + }); + + it("schreibt bei einer schlechteren Verbesserung die letzte Note", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT", false); + + const student = studentFor(4); + + givenBaseline(ctx, student, { note: ctx.notes.positiv }); + + addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) }) + .then((response) => expectNotenSuccess(response, "schlechtere Verbesserung")) + .then(() => readLvGesamtnoteViaDb(ctx, student.uid)) + .then((row) => { + expect(String(row.note), "die LV-Note folgt dem letzten Antritt").to.eq(String(ctx.notes.negativ)); + }); + }); + }); + + // Only one ungraded appointment per course. One graded appointment following the open appointment is still permitted. + describe("nur ein Termin ohne Note", () => { + beforeEach(function () { + requireWiederholung(this, ctx); + }); + + const open = () => ctx.notes.nochNichtEingetragen; + + const openTermine = (data, student) => + attemptsOfStudent(data, student.uid).filter((p) => String(p.note) === String(open())); + + it("lehnt einen zweiten Termin ohne Note ab", () => { + const student = studentFor(0); + + givenBaseline(ctx, student); + addPruefung(ctx, student, { note: open(), datum: attemptDate(ctx, 1) }).then((response) => + expectNotenSuccess(response, "erster offener Termin"), + ); + addPruefung(ctx, student, { note: open(), datum: attemptDate(ctx, 2) }).then((response) => + expectNotenError(response, "pruefungOhneErgebnis"), + ); + + readStateViaApi(ctx).then((data) => { + expect(openTermine(data, student), "offene Termine").to.have.length(1); + }); + }); + + it("lehnt im Sammeldialog je Zeile einen zweiten offenen Termin ab", () => { + const students = [studentFor(0), studentFor(1)]; + const bulkRows = students.map((s) => ({ uid: s.uid, lehreinheit_id: s.lehreinheit_id })); + + resetNotenState(ctx); + students.forEach((s) => seedBaseline(ctx, s)); + + notenApi.createPruefungen(bulkRows, attemptDate(ctx, 1), ctx.lvId, ctx.semKurzbz).then((response) => { + const data = expectNotenSuccess(response, "erster Sammeltermin ohne Note"); + students.forEach((s) => expectBulkRowAccepted(data, s.uid)); + }); + + notenApi.createPruefungen(bulkRows, attemptDate(ctx, 2), ctx.lvId, ctx.semKurzbz).then((response) => { + const data = expectNotenSuccess(response, "zweiter Sammeltermin ohne Note"); + students.forEach((s) => expectBulkRowError(data, s.uid, "pruefungOhneErgebnis")); + }); + }); + + it("lehnt eine Änderung auf 'Noch nicht eingetragen' neben einem offenen Termin ab", () => { + const student = studentFor(2); + let latest; + + givenBaseline(ctx, student); + addPruefung(ctx, student, { note: open(), datum: attemptDate(ctx, 1) }).then((response) => + expectNotenSuccess(response, "offener Termin"), + ); + + // User's decision: An appointment with a grade following the open appointment is still allowed. + addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 2) }).then((response) => { + [latest] = expectNotenSuccess(response, "Termin mit Note nach dem offenen Termin"); + }); + + cy.then(() => + editPruefung(ctx, student, { + pruefungId: latest.pruefung_id, + note: open(), + datum: attemptDate(ctx, 2), + }), + ).then((response) => expectNotenError(response, "pruefungOhneErgebnis")); + }); + }); +}); diff --git a/tests/cypress/e2e/specs/api/noten/noten.nebenlaeufigkeit.cy.js b/tests/cypress/e2e/specs/api/noten/noten.nebenlaeufigkeit.cy.js new file mode 100644 index 000000000..f0a2966dd --- /dev/null +++ b/tests/cypress/e2e/specs/api/noten/noten.nebenlaeufigkeit.cy.js @@ -0,0 +1,100 @@ +/** + * Two concurrent requests for the same student in the same course. + * + * Grades::lockStudent places a transaction-wide advisory lock. The second request waits and + * then reads the state after the first commit. A single run proves little in the event of a race condition, + * so each test repeats the process. + */ + +import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors"; +import { requireConfig, requireWiederholung } from "../../../../support/helpers/notenConfig"; +import { + attemptDate, + baselineDate, + loadNotenContext, + requireDbReset, + resetNotenState, +} from "../../../../support/helpers/notenTestData"; +import { attemptsOfStudent, givenBaseline, readStateViaApi } from "../../../../support/helpers/notenScenario"; + +const ROUNDS = 5; + +const concurrently = (path, bodies) => cy.task("noten:http:parallel", { path, bodies }); + +describe("Noten API - gleichzeitige Schreibvorgänge", () => { + let ctx; + + before(() => { + requireDbReset(); + loadNotenContext().then((context) => { + ctx = context; + }); + }); + + it("legt bei zwei gleichzeitigen Terminen am selben Tag nur einen an", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", false); + requireWiederholung(this, ctx); + + const student = ctx.students[0]; + const datum = attemptDate(ctx, 1); + + // ‘entschuldigt’ does not count as an antritt. Therefore, after the first commit, only the + // date rule rejects the second date, regardless of maxAppearances and the committee rule. + const body = { + student_uid: student.uid, + note: ctx.notes.entschuldigt, + punkte: null, + datum, + lva_id: ctx.lvId, + lehreinheit_id: student.lehreinheit_id, + sem_kurzbz: ctx.semKurzbz, + pruefung_id: null, + }; + + Cypress._.times(ROUNDS, (i) => { + givenBaseline(ctx, student); + + concurrently("saveStudentPruefung", [body, body]).then((responses) => { + const [accepted, rejected] = [...responses].sort((x, y) => x.status - y.status); + expectNotenSuccess(accepted, `Durchlauf ${i + 1}: erster Request`); + expectNotenError(rejected, "pruefungDatumBeforeExisting"); + }); + + readStateViaApi(ctx).then((data) => { + const onDay = attemptsOfStudent(data, student.uid).filter( + (p) => String(p.datum).slice(0, 10) === datum, + ); + expect(onDay, `Durchlauf ${i + 1}: Termine am ${datum}`).to.have.length(1); + }); + }); + }); + + it("schreibt bei zwei gleichzeitigen Übernahmen nur einen Antritt 1", function () { + // Without an initial call, the transfer does not set a time that could cause a race condition + requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true); + + const student = ctx.students[1]; + const body = (note) => ({ + lv_id: ctx.lvId, + sem_kurzbz: ctx.semKurzbz, + student_uid: student.uid, + note, + punkte: null, + datum: baselineDate(ctx), + }); + + Cypress._.times(ROUNDS, (i) => { + resetNotenState(ctx); + + concurrently("saveNotenvorschlag", [body(ctx.gradeNotes[0]), body(ctx.gradeNotes[1])]).then((responses) => { + responses.forEach((response, n) => + expectNotenSuccess(response, `Durchlauf ${i + 1}, Request ${n + 1}`), + ); + }); + + readStateViaApi(ctx).then((data) => { + expect(attemptsOfStudent(data, student.uid), `Durchlauf ${i + 1}: Antritte`).to.have.length(1); + }); + }); + }); +}); diff --git a/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js b/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js index a74a2c74c..ca6e4e398 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js @@ -1,13 +1,13 @@ /** - * Notenschlüssel - Punkte zu Note (P0, case 10). Read-only. + * Grade key - Points per grade. Read-only. * - * Statt die Zuordnung nachzubauen, wird der Bereich abgetastet und auf die Eigenschaften einer - * korrekten Skala geprüft: lückenlos, monoton, exakt an jeder Grenze. + * Instead of recreating the mapping, the range is scanned and checked for the properties of a + * valid scale: continuous, monotonic, and exact at every boundary. */ import { notenApi } from "../../../../support/api/notenApi"; import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors"; -import { requirePunkteModus, skipWenn } from "../../../../support/helpers/notenConfig"; +import { requirePunkteMode, skipIf } from "../../../../support/helpers/notenConfig"; import { attemptDate, loadNotenContext, @@ -54,8 +54,8 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => { `for every value in 0..${MAX_PUNKTE}. Pin NOTEN_LV_ID to an LV that has one.`, ).to.be.greaterThan(0); - // unter der niedrigsten Schwelle antwortet das Modell null; das muss ein zusammenhängender - // Anfang sein, ein null NACH einer Note wäre eine Lücke in der Skala + // Below the lowest threshold, the model returns zero, this must be a continuous + // start. a zero AFTER a note would create a gap in the scale const firstGradedIndex = observed.findIndex((e) => e.note !== null && e.note !== undefined); observed.slice(firstGradedIndex).forEach((entry) => { expect( @@ -64,7 +64,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => { ).to.not.be.oneOf([null, undefined]); }); - // monoton: die PKs laufen 1 (beste) .. 5, die Zahl darf also nie steigen + // Monotonic: The PKs range from 1 (best) to 5, so the number must never increase for (let i = 1; i < graded.length; i += 1) { expect( Number(graded[i].note), @@ -72,7 +72,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => { ).to.be.at.most(Number(graded[i - 1].note)); } - // jede Stufe einzeln melden, damit eine verschobene Schwelle in der Ausgabe sichtbar wird + // Report each level individually so that a shifted threshold is visible in the output const boundaries = []; for (let i = 1; i < graded.length; i += 1) { if (Number(graded[i].note) !== Number(graded[i - 1].note)) { @@ -111,24 +111,24 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => { }); }); - // W3: Punkte ohne ableitbare Note lehnen alle Pfade gleich ab. + // Nodes without a derivable grade reject all paths equally. describe("Punkte ohne ableitbare Note", () => { - let schwelleUnterNull = false; + let thresholdBelowZero = false; before(() => { notenApi.getNoteByPunkte(-1, lvId, ctx.semKurzbz).then((response) => { const note = expectNotenSuccess(response, "getNoteByPunkte(-1)"); - schwelleUnterNull = note !== null && note !== undefined; + thresholdBelowZero = note !== null && note !== undefined; }); }); beforeEach(function () { - requirePunkteModus(this, ctx); - skipWenn(this, schwelleUnterNull, "Übersprungen: der Notenschlüssel hat eine Schwelle unter 0 Punkten."); + requirePunkteMode(this, ctx); + skipIf(this, thresholdBelowZero, "Übersprungen: der Notenschlüssel hat eine Schwelle unter 0 Punkten."); requireDbReset(); }); - const expectNurAntritt1 = (students) => + const expectOnlyAntritt1 = (students) => readStateViaApi(ctx).then((data) => { students.forEach((s) => { expect(attemptsOfStudent(data, s.uid), `Prüfungen von ${s.uid}`).to.have.length(1); @@ -153,7 +153,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => { }) .then((response) => expectNotenError(response, "c4punkteKeineNoteErmittelt")); - expectNurAntritt1([student]); + expectOnlyAntritt1([student]); }); it("createPruefungen lehnt negative Punkte für alle gewählten Studierenden ab", () => { @@ -173,7 +173,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => { ) .then((response) => expectNotenError(response, "c4punkteKeineNoteErmittelt")); - expectNurAntritt1(students); + expectOnlyAntritt1(students); }); }); }); diff --git a/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js b/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js index 9740f1c13..2f468e82d 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js @@ -1,14 +1,14 @@ /** - * Notenvorschlag - LV-Note schreiben und der resultierende Status (P1, case 11). + * Grade proposal - Entering a course grade and the resulting status. * - * Status aus zwei Zeitstempeln (notenRules.js::checkFreigabe). Der "changed nach Freigabe"-Fall - * wird über eine bereits freigegebene Baseline erzeugt, damit dieser Spec ohne LDAP-Passwort und - * ohne Freigabemail auskommt. + * Status determined by two timestamps (notenRules.js::checkFreigabe). The “changed after approval” case + * is generated using a baseline that has already been approved, so that this spec does not require an LDAP password or + * an approval email. */ import { notenApi } from "../../../../support/api/notenApi"; import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors"; -import { requireKonfiguration, requireWiederholung } from "../../../../support/helpers/notenConfig"; +import { requireConfig, requireWiederholung } from "../../../../support/helpers/notenConfig"; import { attemptDate, baselineBenotungsdatum, @@ -82,16 +82,16 @@ describe("Noten API - Notenvorschlag", () => { }); }); - // `erstantritt: false` ist die Altdatenform: freigegebene LV-Note ohne Prüfungszeile. Nur dort - // ist ein direktes Umbenoten erlaubt - existiert eine Prüfung, lehnt der Server ab - // (validateNotenvorschlag, siehe noten.ueberschreiben). - // Das Datum kommt aus dem Übernehmen-Dialog. Die Freigabe macht daraus das Datum von Antritt 1, - // daher gelten dieselben Grenzen wie für einen Prüfungstermin. + // `erstantritt: false` is the legacy data format: approved course grade without an exam entry. Only in this case + // is direct grade adjustment permitted—if an exam exists, the server rejects the request + // (validateGradeSuggestion, see overwriteGrades). + // The date comes from the “Apply” dialog. The approval process uses this as the date of the first attempt, + // so the same restrictions apply as for an exam date. describe("das gewählte Benotungsdatum", () => { - // Die LV-Note IST Antritt 1. Ohne diese Zeile bekäme die nächste Prüfung Termin2, und der - // Legacy-Typ der ganzen Kette verschiebt sich um eine Stelle. + // The lv-note IS “Antritt 1.” Without this line, the next exam would be assigned “Termin2,” and the + // legacy type of the entire chain would shift by one position. it("schreibt Antritt 1 mit dem gewählten Tag", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true); const student = ctx.students[1]; const datum = baselineDate(ctx); @@ -139,7 +139,7 @@ describe("Noten API - Notenvorschlag", () => { }); it("schreibt ohne CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME nur die LV-Note", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false); + requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false); const student = ctx.students[1]; @@ -157,11 +157,11 @@ describe("Noten API - Notenvorschlag", () => { expect(attemptsOfStudent(data, student.uid), "kein Termin").to.have.length(0); }); }); - + // Ohne Erstantritt bei der Übernahme bleibt die LV-Note Antritt 1. Der erste Termin schreibt ihn nach // und wird selbst Antritt 2. it("legt ohne CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME Antritt 1 mit dem ersten Termin an", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false); + requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false); requireWiederholung(this, ctx); const student = ctx.students[2]; @@ -188,14 +188,14 @@ describe("Noten API - Notenvorschlag", () => { }); }); - // W5: Eine Abfrage liest alle LV-Noten der LV. Das Ergebnis je Studierendem bleibt gleich. + // Eine Abfrage liest alle LV-Noten der LV. Das Ergebnis je Studierendem bleibt gleich. describe("Lesedaten", () => { it("liefert LV-Note und Zeitstempel je Studierendem", () => { - const [freigegeben, offen, ohneNote] = ctx.students; + const [freigegeben, open, withoutNote] = ctx.students; resetNotenState(ctx); seedBaseline(ctx, freigegeben, { freigegeben: true }); - seedBaseline(ctx, offen, { freigegeben: false }); + seedBaseline(ctx, open, { freigegeben: false }); readStateViaApi(ctx).then((data) => { const f = lvNoteOf(data, freigegeben.uid); @@ -203,17 +203,17 @@ describe("Noten API - Notenvorschlag", () => { expect(f.freigabedatum, "freigabedatum der freigegebenen Zeile").to.exist; expect(f.benotungsdatum, "benotungsdatum der freigegebenen Zeile").to.exist; - const o = lvNoteOf(data, offen.uid); + const o = lvNoteOf(data, open.uid); expect(o.note_lv, "LV-Note der offenen Zeile").to.exist; expect(o.benotungsdatum, "benotungsdatum der offenen Zeile").to.exist; expect(o.freigabedatum, "die offene Zeile ist nicht freigegeben").to.be.oneOf([null, undefined, ""]); - expect((lvNoteOf(data, ohneNote.uid) || {}).note_lv, "keine LV-Note").to.be.oneOf([null, undefined]); + expect((lvNoteOf(data, withoutNote.uid) || {}).note_lv, "keine LV-Note").to.be.oneOf([null, undefined]); }); }); }); - // W7: Eine einzelne Wiederholung ohne Antritt 1, wie sie die Studierendenverwaltung hinterlassen kann. Dieses + // Eine einzelne Wiederholung ohne Antritt 1, wie sie die Studierendenverwaltung hinterlassen kann. Dieses // Werkzeug erzeugt den Zustand nicht mehr: sein erster Termin schreibt Antritt 1 nach. describe("eine einzelne Wiederholung", () => { it("sperrt die Übernahme", () => { @@ -228,7 +228,7 @@ describe("Noten API - Notenvorschlag", () => { seedBaseline(ctx, student, { erstantritt: false }); let wiederholungId; - seedPruefung(ctx, student, { note: g2, datum: attemptDate(ctx, 1), typ: "Termin2" }).then((seeded) => { + seedPruefung(ctx, student, { note: g2, datum: attemptDate(ctx, 1), type: "Termin2" }).then((seeded) => { wiederholungId = seeded.pruefungId; }); @@ -255,7 +255,7 @@ describe("Noten API - Notenvorschlag", () => { describe("eine bereits freigegebene Note neu benoten", () => { // eine endgültige Freigabe verbietet genau das, siehe noten.freigabe beforeEach(function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", false); + requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", false); }); it("wechselt den Status von freigegeben auf geändert", () => { diff --git a/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js b/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js index 1e891c841..210c78d52 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js @@ -1,20 +1,20 @@ /** - * Prüfungsordnung §1 - Prüfungsantritte. + * Examination Regulations §1 - Taking Exams. * - * Die Regeln greifen in der Reihenfolge A (Maximum) -> B (Chronologie) -> C (Vorkommenslimit). - * Jeder Test baut einen Zustand, in dem genau eine davon feuern kann, sonst maskiert die frühere. + * The rules apply in the following order: A (Maximum) -> B (Chronological) -> C (Occurrence Limit). + * Each test establishes a state in which exactly one of these can trigger; otherwise, the earlier one takes precedence. * - * Gezählt wird über die NOTE: entschuldigt, "Noch nicht eingetragen" und "Nicht beurteilt" zählen - * nie mit. Antritt 1 ist eine echte Prüfung (von givenBaseline geseedet). + * The count is based on the GRADE: entschuldigt, “Noch nicht eingetragen” are + * never counted. Attempt 1 is a real exam (seeded by givenBaseline). */ import { notenApi } from "../../../../support/api/notenApi"; import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors"; import { requireKommissionellerAntritt, - requireKonfiguration, + requireConfig, requireWiederholung, - skipWenn, + skipIf, } from "../../../../support/helpers/notenConfig"; import { attemptDate, @@ -55,9 +55,9 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { // one student per test, so a leaked row cannot reach the next scenario const studentFor = (index) => ctx.students[index % ctx.students.length]; - /** Skip, wenn der letzte Antritt der Kette nicht der kommissionelle ist. */ - const skipOhneKommissionLetzt = (test) => - skipWenn( + /** Skip if the last Antritt in the chain is not the commission link. */ + const skipUnlessLastKommissionell = (test) => + skipIf( test, ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT !== ctx.maxAntritte, "Übersprungen: der letzte Antritt ist nicht kommissionell.", @@ -76,9 +76,9 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { return attemptDate(ctx, firstIndex + ctx.maxAntritte - 1); }; - // Die Konfiguration nennt die Sondernoten nur mit ihrer Bezeichnung. Die API antwortet mit dem - // aufgelösten Primärschlüssel, auf Demodaten ist entschuldigt die 14. Scheitert die Auflösung, - // greifen alle Regeln stillschweigend auf der falschen Note. + // The configuration refers to the special notes only by their labels. The API responds with the + // resolved primary key; on demo data, this is set to 14 by default. If the resolution fails, + // all rules implicitly apply to the incorrect note. it("löst die Sondernoten über ihre Bezeichnung aus tbl_note auf", () => { notenApi.getCisConfig().then((response) => { const config = expectNotenSuccess(response, "getCisConfig"); @@ -98,7 +98,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { describe("Regel A - die Höchstzahl der Prüfungsantritte", () => { it("lehnt einen Antritt ab, sobald das konfigurierte Maximum erreicht ist", function () { // ohne Anlage der kommissionellen Prüfung endet die Kette früher, siehe den Test zu kommPruefNichtErlaubt - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true); const student = studentFor(0); @@ -157,7 +157,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { }); }); - describe("Regel C - 'entschuldigt' gilt nur einmal", () => { + describe("'entschuldigt' gilt nur einmal", () => { beforeEach(function () { requireWiederholung(this, ctx); }); @@ -216,18 +216,18 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { }); describe("die kommissionelle Prüfung ist der letzte Antritt", () => { - // §17 Abs 1: die zweite Wiederholung ist kommissionell. Der Server leitet den Typ aus der - // Position ab und schreibt ihn in pruefungstyp_kurzbz, weil die Studierendenverwaltung - // diese Spalte weiterhin liest. + // §17, Abs 1: The second retake is kommissionell. The server derives the type from the + // position and writes it to `pruefungstyp_kurzbz` because the student administration + // system still reads this column. it("schreibt den letzten Antritt als kommissionelle Prüfung", function () { requireKommissionellerAntritt(this, ctx); - skipOhneKommissionLetzt(this); + skipUnlessLastKommissionell(this); const student = studentFor(3); givenBaseline(ctx, student); - // bis zum vorletzten Antritt auffüllen + // Fill up to the second-to-last step for (let i = 1; i < ctx.maxAntritte - 1; i += 1) { addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, i) }).then((response) => expectNotenSuccess(response, `Antritt ${i + 1}`), @@ -250,18 +250,16 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { expect(verlauf.canAdd, "danach ist kein Antritt mehr möglich").to.be.false; }); }); - - // Die Anlage kann abgeschaltet sein, wenn eine Installation die kommissionelle Prüfung in - // einem anderen Werkzeug einträgt. Angezeigt wird sie trotzdem, nur angelegt nicht. + it("verweigert den letzten Antritt, wenn das Tool ihn nicht anlegen darf", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", false); - skipOhneKommissionLetzt(this); + requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", false); + skipUnlessLastKommissionell(this); const student = studentFor(4); givenBaseline(ctx, student); - // bis zum vorletzten Antritt auffüllen; danach wäre der kommissionelle fällig + // Fill up to the second-to-last step for (let i = 1; i < ctx.maxAntritte - 1; i += 1) { addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, i) }).then((response) => expectNotenSuccess(response, `Antritt ${i + 1}`), @@ -280,8 +278,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { }).then((response) => expectNotenError(response, "kommPruefNichtErlaubt")); }); - // Auch eine kommissionelle Prüfung ohne zählende Note schliesst die Kette. Sonst liesse sich - // nach einer noch unbenoteten kommissionellen ein weiterer Antritt anlegen. + // Even a kommissionelle pruefung without a graded score completes the chain. Otherwise, + // it would be possible to schedule another attempt after a committee exam that has not yet been graded. it("sperrt weitere Antritte auch bei einer kommPruef ohne zählende Note", () => { const student = studentFor(1); @@ -290,7 +288,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { seedPruefung(ctx, student, { note: ctx.notes.nochNichtEingetragen, datum: attemptDate(ctx, 1), - typ: "kommPruef", + type: "kommPruef", }); readStateViaApi(ctx).then((data) => { @@ -319,11 +317,11 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { ); readStateViaApi(ctx).then((data) => { - const offen = attemptsOfStudent(data, student.uid).find( + const open = attemptsOfStudent(data, student.uid).find( (p) => String(p.note) === String(ctx.notes.nochNichtEingetragen), ); - expect(offen, "der offene Termin").to.exist; - expect(offen.zaehlt, "der offene Termin verbraucht keinen Antritt").to.be.false; + expect(open, "der offene Termin").to.exist; + expect(open.zaehlt, "der offene Termin verbraucht keinen Antritt").to.be.false; const verlauf = verlaufOfStudent(data, student.uid); expect(verlauf.antrittCount, "nur Antritt 1").to.eq(1); @@ -335,7 +333,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { describe("Anrechnung - gar keine Prüfungen", () => { it("lehnt einen Antritt bei angerechneter Zeugnisnote ab", function () { const angerechnet = (ctx.cisConfig.NOTEN_ANRECHNUNG || [])[0]; - skipWenn(this, angerechnet === undefined, "Übersprungen: NOTEN_ANRECHNUNG löst keine Note auf."); + skipIf(this, angerechnet === undefined, "Übersprungen: NOTEN_ANRECHNUNG löst keine Note auf."); const student = studentFor(4); @@ -359,7 +357,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { requireWiederholung(this, ctx); }); - /** Antritt 1 < entschuldigt < echte Note; liefert die entschuldigte Zeile (Nachbarn beidseits). */ + /** Antritt 1 < entschuldigt < actual grade, returns the excused row (neighbors on both sides). */ const givenThreeAttempts = (student) => { givenBaseline(ctx, student); @@ -376,7 +374,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { }; it("lehnt eine Notenänderung nach einem späteren Antritt ab", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", true); const student = studentFor(1); diff --git a/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js b/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js index 32be13b19..04a93ca3d 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js @@ -1,13 +1,13 @@ /** - * Der Schreibpfad (savePruefungFuerStudent), den saveStudentPruefung, createPruefungen und - * savePruefungenBulk teilen. Die Validatoren decken die anderen Specs ab. + * The write path (savePruefungFuerStudent) shared by saveStudentPruefung, createPruefungen, and + * savePruefungenBulk. The validators cover the other specs. * - * Invariante: eine Aktion schreibt genau eine Prüfung. Ausnahme: neben einer LV-Note ohne Prüfungszeile - * schreibt der erste neue Termin zuerst Antritt 1. + * Invariant: An action saves exactly one exam. Exception: In addition to a course grade without an exam row, + * the first new date first saves “Attendance 1.” */ import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors"; -import { requireKonfiguration, requireWiederholung } from "../../../../support/helpers/notenConfig"; +import { requireConfig, requireWiederholung } from "../../../../support/helpers/notenConfig"; import { attemptDate, baselineDate, @@ -92,19 +92,19 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => { const verlauf = verlaufOfStudent(data, student.uid); expect(verlauf.antrittCount, "both attempts counted").to.eq(2); expect(verlauf.maxAntritte, "the cap comes from the server").to.eq(ctx.maxAntritte); - // der letzte Antritt ist kommissionell; darf das Tool ihn nicht anlegen, ist hier Schluss - const anlegbar = + // The last entry is kommissionell. if the tool cannot create it, the process ends here + const creatable = ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF !== false ? ctx.maxAntritte : ctx.maxAntritte - 1; - expect(verlauf.canAdd, `canAdd with 2 of ${anlegbar} possible`).to.eq(2 < anlegbar); + expect(verlauf.canAdd, `canAdd with 2 of ${creatable} possible`).to.eq(2 < creatable); }); }); it("hängt je Anlage eine neue Zeile an, statt die vorherige zu überschreiben", function () { requireWiederholung(this, ctx); - // nur eine explizite pruefung_id aktualisiert, ein Add fügt immer ein + // Only an explicit “pruefung_id” is updating, an “Add” always inserts one const student = studentFor(0); givenBaseline(ctx, student); @@ -138,10 +138,10 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => { givenBaseline(ctx, student); let excusedId; - seedPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: excusedDate, typ: "Termin2" }) + seedPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: excusedDate, type: "Termin2" }) .then((seeded) => { excusedId = seeded.pruefungId; - return seedPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: gradedDate, typ: "Termin2" }); + return seedPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: gradedDate, type: "Termin2" }); }) // note stays entschuldigt: a later pruefung exists, so only the datum may move .then(() => @@ -182,8 +182,8 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => { }); }); - // Eine LV-Note ohne Prüfungszeile ist Antritt 1: aus einer Übernahme ohne Erstantritt oder aus der Zeit - // vor dem Werkzeug. Ohne die Zeile fiele sie aus der Zählung, und der Studierende bekäme einen Antritt zu viel. + // A course grade without an exam line is considered “Antritt 1”: either from a Übernahme without an initial attendance or from the period + // before the tool was implemented. Without that line, it would be omitted from the count, and the student would be credited with one too many attendances. it("schreibt Antritt 1 aus einer LV-Note ohne Prüfungszeile nach", function () { requireWiederholung(this, ctx); @@ -207,9 +207,9 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => { }); }); - // Regel: Die LV-Note ist die Note des letzten Termins, der einen Antritt verbraucht. Ohne einen - // solchen Termin bleibt ein impliziter Erstantritt, sonst gilt "Noch nicht eingetragen". - // Die LV-Note ist nie 'entschuldigt'. + // Rule: The course grade is the grade from the last session that counts as an attendance. Without such + // a session, an implicit first attendance is assumed; otherwise, the status is “Noch nicht eingetragen” + // The course grade is never marked as “entschuldigt” describe("LV-Note nach einem Termin", () => { const g2 = () => ctx.notes.positiv ?? @@ -222,9 +222,9 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => { return row; }); - const expectAntritte = (student, anzahl) => + const expectAntritte = (student, count) => readStateViaApi(ctx).then((data) => { - expect(verlaufOfStudent(data, student.uid).antrittCount, "antrittCount").to.eq(anzahl); + expect(verlaufOfStudent(data, student.uid).antrittCount, "antrittCount").to.eq(count); }); it("behält die LV-Note bei einer Datumskorrektur an Termin 1", function () { @@ -267,7 +267,7 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => { }); // Kein requireWiederholung: der Test braucht nur Antritt 1. Mit zwei Antritten ohne - // kommissionelle Anlage scheitert er, siehe docs/benotungstool-status.md, Abschnitt 9. + // kommissionelle Anlage scheitert er it("setzt 'Noch nicht eingetragen', wenn der erste Termin entschuldigt ist", () => { const student = studentFor(2); @@ -291,10 +291,10 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => { ); readStateViaApi(ctx).then((data) => { - const zaehlend = attemptsOfStudent(data, student.uid).find( + const counting = attemptsOfStudent(data, student.uid).find( (p) => String(p.note) === String(ctx.notes.negativ), ); - expect(zaehlend.antritt_nr, "der Studierende hat keinen Antritt verloren").to.eq(1); + expect(counting.antritt_nr, "der Studierende hat keinen Antritt verloren").to.eq(1); }); }); @@ -315,7 +315,7 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => { .then((response) => expectNotenSuccess(response, "Termin 1 wird entschuldigt")); expectLvNote(student, ctx.notes.nochNichtEingetragen, "die LV-Note ist nie entschuldigt"); - // die alte LV-Note zählt nicht als impliziter Erstantritt + // The old course grade does not count as an implicit first enrollment expectAntritte(student, 0); }); @@ -387,11 +387,11 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => { describe("Freigabe nach einem neuen Termin", () => { beforeEach(function () { // eine endgültige Freigabe verbietet den neuen Termin - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", false); + requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", false); requireWiederholung(this, ctx); }); - const neuerTerminNachFreigabe = (student) => { + const newTerminAfterFreigabe = (student) => { givenBaseline(ctx, student, { freigegeben: true }); addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) }).then((response) => expectNotenSuccess(response, "Termin nach der Freigabe"), @@ -400,18 +400,18 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => { }; it("hebt die Freigabe mit einem neuen Termin auf", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF", true); - neuerTerminNachFreigabe(studentFor(5)).then((row) => { + newTerminAfterFreigabe(studentFor(5)).then((row) => { expect(new Date(row.benotungsdatum) > new Date(row.freigabedatum), "benotungsdatum nach freigabedatum") .to.be.true; }); }); it("behält die Freigabe bei einem neuen Termin", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF", false); + requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF", false); - neuerTerminNachFreigabe(studentFor(5)).then((row) => { + newTerminAfterFreigabe(studentFor(5)).then((row) => { expect(new Date(row.benotungsdatum) > new Date(row.freigabedatum), "benotungsdatum nach freigabedatum") .to.be.false; }); diff --git a/tests/cypress/e2e/specs/api/noten/noten.rollenmatrix.cy.js b/tests/cypress/e2e/specs/api/noten/noten.rollenmatrix.cy.js new file mode 100644 index 000000000..2ad85f68c --- /dev/null +++ b/tests/cypress/e2e/specs/api/noten/noten.rollenmatrix.cy.js @@ -0,0 +1,171 @@ +/** + * The role matrix: which permission may do which action. + * + * Each account asks getCisConfig for its actions. Each action has two tests: one runs when the matrix + * allows the action, the other when the matrix withholds it. The profile "rollen" withholds the release + * and the kommissionelle Prüfung from the Assistenz, otherwise the second test never runs. + */ + +import { expectNotenError, expectNotenSuccess, messageMatchesPhrase } from "../../../../support/helpers/notenErrors"; +import { requireKommissionellerAntritt, skipIf } from "../../../../support/helpers/notenConfig"; +import { attemptDate, loadNotenContext, requireDbReset } from "../../../../support/helpers/notenTestData"; +import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario"; + +const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten"; + +const accounts = () => [ + { + label: "Lektor", + user: Cypress.env("NOTEN_TEACHER_USER"), + pass: Cypress.env("NOTEN_TEACHER_PASSWORD"), + }, + { + label: "Assistenz", + user: Cypress.env("NOTEN_ASSISTENZ_USER"), + pass: Cypress.env("NOTEN_ASSISTENZ_PASSWORD"), + }, +]; + +// the first statement of each endpoint checks its action; kommpruef is checked at the kommissionell attempt +const ENDPOINT = { + vorschlag: "saveNotenvorschlag", + pruefung: "saveStudentPruefung", + freigabe: "saveStudentenNoten", + import: "saveNotenvorschlagBulk", +}; + +/** A call that only reaches the action check. The empty body ends in missingParameters otherwise. */ +const callAction = (auth, action, ctx) => + cy.request({ + method: "POST", + url: `${NOTEN_API}/${ENDPOINT[action]}`, + body: { lv_id: ctx.lvId, sem_kurzbz: ctx.semKurzbz }, + auth, + failOnStatusCode: false, + }); + +const errorMessages = (response) => ((response.body && response.body.errors) || []).map((e) => e.message); + +describe("Noten API - die Rollenmatrix", () => { + let ctx; + + before(() => { + loadNotenContext().then((context) => { + ctx = context; + }); + }); + + accounts().forEach(({ label, user, pass }) => { + describe(label, () => { + const auth = { username: user, password: pass }; + let allowed = []; + + before(function () { + skipIf(this, !user || !pass, `Übersprungen: kein Konto für "${label}" in tests/cypress/.env.`); + + cy.clearAllCookies(); + cy.request({ method: "GET", url: `${NOTEN_API}/getCisConfig`, auth, failOnStatusCode: false }).then( + (response) => { + // every account of the suite holds a permission of the matrix; seeder group benotungstool_berechtigungen creates it + expect(response.status, `getCisConfig als ${label}`).to.eq(200); + allowed = response.body.data.CIS_GESAMTNOTE_AKTIONEN || []; + cy.log(`${label} darf: ${JSON.stringify(allowed)}`); + }, + ); + }); + + // cy.request sends the cookie of the suite user otherwise, and the server prefers the cookie + beforeEach(() => cy.clearAllCookies()); + + Object.keys(ENDPOINT).forEach((action) => { + it(`führt "${action}" aus, wenn die Matrix es erlaubt`, function () { + skipIf(this, !allowed.includes(action), `Übersprungen: die Matrix entzieht ${label} "${action}".`); + + callAction(auth, action, ctx).then((res) => { + expect( + errorMessages(res).some((m) => messageMatchesPhrase(m, "aktionNichtErlaubt")), + `${label} darf "${action}": ${JSON.stringify(errorMessages(res))}`, + ).to.be.false; + }); + }); + + it(`lehnt "${action}" ab, wenn die Matrix es entzieht`, function () { + skipIf(this, allowed.includes(action), `Übersprungen: die Matrix erlaubt ${label} "${action}".`); + + callAction(auth, action, ctx).then((res) => expectNotenError(res, "aktionNichtErlaubt")); + }); + }); + + describe("der kommissionelle Antritt", () => { + const student = () => ctx.students[6]; + + beforeEach(function () { + requireKommissionellerAntritt(this, ctx); + requireDbReset(); + }); + + /** The suite user builds the chain up to the kommissionell attempt. Returns its date. */ + const bringToBeforeKommission = () => { + const fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT; + + givenBaseline(ctx, student()); + for (let nr = 2; nr < fromAntritt; nr += 1) { + addPruefung(ctx, student(), { note: ctx.notes.negativ, datum: attemptDate(ctx, nr - 1) }).then( + (response) => expectNotenSuccess(response, `Antritt ${nr}`), + ); + } + return attemptDate(ctx, fromAntritt - 1); + }; + + const createKommissionell = (datum) => { + const s = student(); + // the chain requests can leave a session cookie of the suite user + cy.clearAllCookies(); + return cy.request({ + method: "POST", + url: `${NOTEN_API}/saveStudentPruefung`, + body: { + student_uid: s.uid, + note: ctx.notes.negativ, + punkte: null, + datum, + lva_id: ctx.lvId, + lehreinheit_id: s.lehreinheit_id, + sem_kurzbz: ctx.semKurzbz, + pruefung_id: null, + }, + auth, + failOnStatusCode: false, + }); + }; + + it("legt ihn an, wenn die Matrix es erlaubt", function () { + skipIf( + this, + !allowed.includes("kommpruef"), + `Übersprungen: die Matrix entzieht ${label} "kommpruef".`, + ); + + createKommissionell(bringToBeforeKommission()).then((response) => { + const [saved] = expectNotenSuccess(response, `kommissioneller Antritt als ${label}`); + expect(saved.pruefungstyp_kurzbz, "der Antritt ist kommissionell").to.eq( + ctx.cisConfig.PRUEFUNG_TYP_KOMMISSIONELL, + ); + }); + }); + + it("lehnt ihn ab, wenn die Matrix es entzieht", function () { + skipIf( + this, + allowed.includes("kommpruef"), + `Übersprungen: die Matrix erlaubt ${label} "kommpruef".`, + ); + + createKommissionell(bringToBeforeKommission()).then((response) => + expectNotenError(response, "kommPruefNichtErlaubt"), + ); + }); + }); + }); + }); +}); diff --git a/tests/cypress/e2e/specs/api/noten/noten.scope.cy.js b/tests/cypress/e2e/specs/api/noten/noten.scope.cy.js new file mode 100644 index 000000000..b83e7cc95 --- /dev/null +++ b/tests/cypress/e2e/specs/api/noten/noten.scope.cy.js @@ -0,0 +1,480 @@ +/** + * Scope of write paths: An exam belongs to the student and the course associated with the request. + * + * An edit affects only one exam for this student in this course. The server selects the + * course, and a non-participant does not receive a grade. Both rejections report + * c4pruefungNichtGespeichert. A genuine write error reports the same message, which is why every + * test also checks the database. + * + * The seeder group `benotungstool_fixture_erweitert` creates a second course with two instructors. + */ + +import { notenApi, notenAuth, pruefungenOf } from "../../../../support/api/notenApi"; +import { + expectBulkRowAccepted, + expectBulkRowError, + expectNotenError, + expectNotenSuccess, +} from "../../../../support/helpers/notenErrors"; +import { requireNotenMode, requireWiederholung, skipIf } from "../../../../support/helpers/notenConfig"; +import { assistenzAuth, requireAssistenz } from "../../../../support/helpers/notenAssistenz"; +import { + attemptDate, + baselineDate, + loadNotenContext, + readLvGesamtnoteViaDb, + requireDbReset, + resetNotenState, + seedBaseline, + seedPruefung, + shiftDate, +} from "../../../../support/helpers/notenTestData"; +import { attemptsOfStudent, editPruefung, readStateViaApi } from "../../../../support/helpers/notenScenario"; + +const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten"; + +const dayOf = (value) => String(value).slice(0, 10); + +describe("Noten API - Scope der Schreibpfade", () => { + let ctx; + let a; + let b; + + // null: die Instanz kann den Fall nicht bauen, der Test wird übersprungen + let outsider = null; + let foreign = null; + let otherLe = null; + let typeWithoutAntritt = null; + + const lvScope = () => ({ lvId: ctx.lvId, semKurzbz: ctx.semKurzbz }); + + /** A student with exactly one course unit and another course unit from the same course. */ + const findOtherLe = (lehreinheiten, i = 0) => { + if (lehreinheiten.length < 2 || i >= ctx.students.length) return cy.wrap(null, { log: false }); + + const student = ctx.students[i]; + return cy.task("noten:db:lehreinheitenOfStudent", { ...lvScope(), studentUid: student.uid }).then((own) => { + if (own.length !== 1) return findOtherLe(lehreinheiten, i + 1); + const other = lehreinheiten.find((le) => String(le) !== String(own[0])); + return { student, ownLe: own[0], otherLe: other }; + }); + }; + + /** The first type from PRUEFUNG_TYPEN_OHNE_ANTRITT that tbl_pruefungstyp contains. */ + const findTypeWithoutAntritt = (types, i = 0) => { + if (i >= types.length) return cy.wrap(null, { log: false }); + + return cy + .task("noten:db:pruefungstypExists", { type: types[i] }) + .then((found) => (found ? types[i] : findTypeWithoutAntritt(types, i + 1))); + }; + + before(() => { + requireDbReset(); + loadNotenContext().then((context) => { + ctx = context; + [a, b] = ctx.students; + + cy.task("noten:db:nonParticipant", lvScope()).then((uid) => { + outsider = uid; + }); + cy.task("noten:db:foreignLehreinheit", lvScope()).then((row) => { + foreign = row; + }); + findTypeWithoutAntritt(ctx.cisConfig.PRUEFUNG_TYPEN_OHNE_ANTRITT || []).then((type) => { + typeWithoutAntritt = type; + }); + cy.task("noten:db:lehreinheitenOfLv", lvScope()) + .then((lehreinheiten) => findOtherLe(lehreinheiten)) + .then((found) => { + otherLe = found; + }); + }); + }); + + describe("ein Edit trifft nur eine eigene Prüfung", () => { + /** Die Prüfung von B, die der Request für A bearbeiten will. */ + const seedForeignRow = () => + seedPruefung(ctx, b, { note: ctx.notes.negativ, datum: baselineDate(ctx), type: "Termin1" }).then( + (seeded) => seeded.pruefungId, + ); + + const expectForeignRowUnchanged = (foreignId) => + readStateViaApi(ctx).then((data) => { + const storedPruefung = pruefungenOf(data, b.uid).find( + (p) => String(p.pruefung_id) === String(foreignId), + ); + expect(storedPruefung, "die Prüfung von B existiert weiter").to.exist; + expect(String(storedPruefung.note), "die Prüfung von B behält ihre Note").to.eq( + String(ctx.notes.negativ), + ); + expect(dayOf(storedPruefung.datum), "die Prüfung von B behält ihr Datum").to.eq(baselineDate(ctx)); + }); + + it("lehnt die Prüfung eines anderen Studierenden ab", () => { + const otherNote = ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ)); + let foreignId; + let lvNoteBefore; + + resetNotenState(ctx); + seedBaseline(ctx, a); + seedBaseline(ctx, b, { erstantritt: false }); + seedForeignRow().then((id) => { + foreignId = id; + }); + readLvGesamtnoteViaDb(ctx, a.uid).then((row) => { + lvNoteBefore = row; + }); + + cy.then(() => + editPruefung(ctx, a, { + pruefungId: foreignId, + note: otherNote, + datum: shiftDate(baselineDate(ctx), 3), + }), + ).then((response) => expectNotenError(response, "c4pruefungNichtGespeichert")); + + cy.then(() => expectForeignRowUnchanged(foreignId)); + readLvGesamtnoteViaDb(ctx, a.uid).then((row) => { + expect(String(row.note), "die LV-Note von A bleibt").to.eq(String(lvNoteBefore.note)); + expect(String(row.benotungsdatum), "das Benotungsdatum von A bleibt").to.eq( + String(lvNoteBefore.benotungsdatum), + ); + }); + }); + + it("lehnt eine fremde Prüfung für einen Studierenden ohne Prüfung ab", () => { + let foreignId; + + resetNotenState(ctx); + seedForeignRow().then((id) => { + foreignId = id; + }); + + cy.then(() => + editPruefung(ctx, a, { + pruefungId: foreignId, + note: ctx.gradeNotes[0], + datum: shiftDate(baselineDate(ctx), 3), + }), + ).then((response) => expectNotenError(response, "c4pruefungNichtGespeichert")); + + readLvGesamtnoteViaDb(ctx, a.uid).then((row) => { + expect(row, "A bekommt keine LV-Note").to.be.null; + }); + cy.then(() => expectForeignRowUnchanged(foreignId)); + }); + + // Der Server sperrt jeden Typ aus PRUEFUNG_TYPEN_OHNE_ANTRITT für Edits. + // Aktuell: zusKommPruef, den die Studierendenverwaltung einträgt. + it("lehnt eine Prüfung der Studierendenverwaltung ab", function () { + skipIf( + this, + !typeWithoutAntritt, + "Übersprungen: kein Typ aus PRUEFUNG_TYPEN_OHNE_ANTRITT in tbl_pruefungstyp.", + ); + + const datum = attemptDate(ctx, 1); + let pruefungId; + + resetNotenState(ctx); + seedBaseline(ctx, a); + seedPruefung(ctx, a, { note: ctx.notes.negativ, datum, type: typeWithoutAntritt }).then((seeded) => { + pruefungId = seeded.pruefungId; + }); + + cy.then(() => + editPruefung(ctx, a, { pruefungId, note: ctx.notes.negativ, datum: shiftDate(datum, 2) }), + ).then((response) => expectNotenError(response, "c4pruefungNichtGespeichert")); + + readStateViaApi(ctx).then((data) => { + const storedPruefung = pruefungenOf(data, a.uid).find( + (p) => String(p.pruefung_id) === String(pruefungId), + ); + expect(storedPruefung, `die Prüfung vom Typ ${typeWithoutAntritt} existiert weiter`).to.exist; + expect(dayOf(storedPruefung.datum), "sie behält ihr Datum").to.eq(datum); + }); + }); + }); + + describe("die Lehreinheit einer Prüfung", () => { + const terminFor = (uid, lehreinheitId) => + notenApi.saveStudentPruefung({ + student_uid: uid, + note: ctx.notes.negativ, + punkte: null, + datum: attemptDate(ctx, 1), + lva_id: ctx.lvId, + lehreinheit_id: lehreinheitId, + sem_kurzbz: ctx.semKurzbz, + pruefung_id: null, + }); + + // The server replaces the teaching unit without displaying an error message. + it("ersetzt eine andere Lehreinheit derselben LV", function () { + skipIf(this, !otherLe, "Übersprungen: die LV hat keine zweite Lehreinheit."); + requireWiederholung(this, ctx); + + const { student, ownLe } = otherLe; + + resetNotenState(ctx); + seedBaseline(ctx, student); + + terminFor(student.uid, otherLe.otherLe).then((response) => { + const [saved] = expectNotenSuccess(response, "Termin mit einer anderen Lehreinheit"); + expect(String(saved.lehreinheit_id), "die Lehreinheit des Studierenden").to.eq(String(ownLe)); + }); + }); + + it("ersetzt die Lehreinheit einer fremden LV", function () { + skipIf(this, !foreign, "Übersprungen: das Semester hat keine Lehreinheit einer anderen LV."); + requireWiederholung(this, ctx); + + resetNotenState(ctx); + seedBaseline(ctx, a); + + terminFor(a.uid, foreign.lehreinheit_id) + .then((response) => { + // resetNotenState does not reach a line in the external LV + const saved = response.body?.data?.[0]; + if (saved && String(saved.lehreinheit_id) === String(foreign.lehreinheit_id)) { + cy.task("noten:db:deletePruefung", { pruefungId: saved.pruefung_id, studentUids: [a.uid] }); + } + return cy.wrap(response, { log: false }); + }) + .then((response) => { + const [saved] = expectNotenSuccess(response, "Termin mit einer fremden Lehreinheit"); + return cy + .task("noten:db:lehreinheitenOfStudent", { ...lvScope(), studentUid: a.uid }) + .then((own) => { + expect(own.map(String), "eine Lehreinheit des Studierenden").to.include( + String(saved.lehreinheit_id), + ); + }); + }); + + readStateViaApi(ctx).then((data) => { + expect(attemptsOfStudent(data, a.uid), "Antritt 1 und der neue Termin").to.have.length(2); + }); + }); + + describe("Nicht-Teilnehmer", () => { + beforeEach(function () { + skipIf(this, !outsider, "Übersprungen: kein aktiver Studierender ausserhalb der LV."); + resetNotenState(ctx, [outsider]); + }); + + // The reset is the check: It clears what the request wrote and counts it. + const expectNothingWritten = () => + resetNotenState(ctx, [outsider]).then((deleted) => { + expect(deleted.deletedLvGesamtnoten, `LV-Noten von ${outsider}`).to.eq(0); + expect(deleted.deletedPruefungen, `Prüfungen von ${outsider}`).to.eq(0); + }); + + it("saveStudentPruefung legt nichts an", () => { + terminFor(outsider, a.lehreinheit_id).then((response) => + expectNotenError(response, "c4pruefungNichtGespeichert"), + ); + expectNothingWritten(); + }); + + it("createPruefungen lehnt nur die Zeile des Nicht-Teilnehmers ab", () => { + resetNotenState(ctx); + + notenApi + .createPruefungen( + [ + { uid: a.uid, lehreinheit_id: a.lehreinheit_id }, + { uid: outsider, lehreinheit_id: a.lehreinheit_id }, + ], + attemptDate(ctx, 1), + ctx.lvId, + ctx.semKurzbz, + ) + .then((response) => { + const data = expectNotenSuccess(response, "createPruefungen"); + expectBulkRowError(data, outsider, "c4pruefungNichtGespeichert"); + expect(data[a.uid].savedPruefung, `Prüfung für ${a.uid}`).to.exist; + }); + expectNothingWritten(); + }); + + it("savePruefungenBulk lehnt nur die Zeile des Nicht-Teilnehmers ab", function () { + requireNotenMode(this, ctx); + + const bulkRow = (uid) => ({ + uid, + note: ctx.notes.negativ, + punkte: null, + datum: attemptDate(ctx, 1), + lehreinheit_id: a.lehreinheit_id, + }); + + resetNotenState(ctx); + + notenApi + .savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [bulkRow(a.uid), bulkRow(outsider)]) + .then((response) => { + const data = expectNotenSuccess(response, "savePruefungenBulk"); + expectBulkRowError(data, outsider, "c4pruefungNichtGespeichert"); + expectBulkRowAccepted(data, a.uid); + }); + expectNothingWritten(); + }); + + it("saveNotenvorschlag schreibt keine LV-Note", () => { + notenApi + .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, outsider, ctx.notes.negativ) + .then((response) => expectNotenError(response, "c4pruefungNichtGespeichert")); + expectNothingWritten(); + }); + + it("saveNotenvorschlagBulk lehnt nur die Zeile des Nicht-Teilnehmers ab", function () { + requireNotenMode(this, ctx); + + resetNotenState(ctx); + + notenApi + .saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [ + { uid: a.uid, note: ctx.gradeNotes[0], punkte: null }, + { uid: outsider, note: ctx.gradeNotes[0], punkte: null }, + ]) + .then((response) => { + const data = expectNotenSuccess(response, "saveNotenvorschlagBulk"); + expectBulkRowError(data, outsider, "c4pruefungNichtGespeichert"); + expectBulkRowAccepted(data, a.uid); + }); + expectNothingWritten(); + }); + }); + + describe("getLehrendeFuerLehreinheit", () => { + it("nennt die Lehrenden einer Lehreinheit der LV", () => { + notenApi.getLehrendeFuerLehreinheit(a.lehreinheit_id, ctx.lvId, ctx.semKurzbz).then((response) => { + expect(expectNotenSuccess(response, "eigene Lehreinheit"), "Lehrende").to.be.an("array"); + }); + }); + + it("lehnt die Lehreinheit einer fremden LV ab", function () { + skipIf(this, !foreign, "Übersprungen: das Semester hat keine Lehreinheit einer anderen LV."); + + notenApi + .getLehrendeFuerLehreinheit(foreign.lehreinheit_id, ctx.lvId, ctx.semKurzbz) + .then((response) => expectNotenError(response, "wrongParameters")); + }); + + it("lehnt eine nicht numerische Lehreinheit ab", () => { + notenApi + .getLehrendeFuerLehreinheit("abc", ctx.lvId, ctx.semKurzbz) + .then((response) => expectNotenError(response, "missingParameters")); + }); + }); + }); + + // The person listed as the grader for the exam and course grade: a valid selection, otherwise, the caller if they + // teach the lehreinheit, otherwise, the first instructor of the lehreinheit based on the UID. + describe("die benotende Person", () => { + // ein Studierender, dessen Lehreinheit mehrere Lehrende hat; der Suite-Benutzer steht nicht an erster Stelle + let target = null; + let suiteUid = null; + + before(() => { + const search = (i) => + i >= ctx.students.length + ? cy.wrap(null, { log: false }) + : cy + .task("noten:db:lehrendeOfLehreinheit", { lehreinheitId: ctx.students[i].lehreinheit_id }) + .then((lehrende) => + lehrende.length > 1 && lehrende.includes(suiteUid) && lehrende[0] !== suiteUid + ? { student: ctx.students[i], lehrende } + : search(i + 1), + ); + + cy.request({ + method: "GET", + url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID", + auth: notenAuth(), + }) + .then((response) => { + suiteUid = response.body.data.uid; + return search(0); + }) + .then((found) => { + target = found; + }); + }); + + beforeEach(() => { + expect( + target, + "eine Lehreinheit mit mehreren Lehrenden, darunter der Suite-Benutzer (Seeder-Gruppe benotungstool_fixture_erweitert)", + ).to.not.be.null; + requireDbReset(); + resetNotenState(ctx); + }); + + const terminBody = (mitarbeiter_uid) => ({ + student_uid: target.student.uid, + note: ctx.notes.negativ, + punkte: null, + datum: attemptDate(ctx, 1), + lva_id: ctx.lvId, + lehreinheit_id: target.student.lehreinheit_id, + sem_kurzbz: ctx.semKurzbz, + pruefung_id: null, + mitarbeiter_uid, + }); + + const grader = (response) => { + const [saved] = expectNotenSuccess(response, "Termin"); + return cy.task("noten:db:pruefungMitarbeiter", { + pruefungId: saved.pruefung_id, + studentUid: target.student.uid, + }); + }; + + it("trägt den Aufrufer ein, wenn er die Lehreinheit unterrichtet", () => { + notenApi + .saveStudentPruefung(terminBody()) + .then(grader) + .then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(suiteUid)); + + readLvGesamtnoteViaDb(ctx, target.student.uid).then((row) => { + expect(row.mitarbeiter_uid, "benotende Person der LV-Note").to.eq(suiteUid); + }); + }); + + it("trägt die gewählte Lehrperson ein", () => { + const chosen = target.lehrende.find((uid) => uid !== suiteUid); + + notenApi + .saveStudentPruefung(terminBody(chosen)) + .then(grader) + .then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(chosen)); + }); + + it("übergeht eine Auswahl ausserhalb der Lehreinheit", () => { + notenApi + .saveStudentPruefung(terminBody("keine-lehrperson")) + .then(grader) + .then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(suiteUid)); + }); + + it("trägt ohne eigene Lehre die erste Lehrperson der Lehreinheit ein", function () { + requireAssistenz(this); + + // otherwise, the request will be processed using the Suite user's cookie + cy.clearAllCookies(); + + cy.request({ + method: "POST", + url: `${NOTEN_API}/saveStudentPruefung`, + body: terminBody(), + auth: assistenzAuth(), + failOnStatusCode: false, + }) + .then(grader) + .then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(target.lehrende[0])); + }); + }); +}); diff --git a/tests/cypress/e2e/specs/api/noten/noten.terminregeln.cy.js b/tests/cypress/e2e/specs/api/noten/noten.terminregeln.cy.js new file mode 100644 index 000000000..69adefac4 --- /dev/null +++ b/tests/cypress/e2e/specs/api/noten/noten.terminregeln.cy.js @@ -0,0 +1,299 @@ +/** + * Configurable exam date guards. + * + * No rule value is hardcoded. Each switch has one test per branch. A test skips itself when the other + * branch is configured, and a profile in tests/cypress/profiles/noten.js runs it. + */ + +import { notenApi } from "../../../../support/api/notenApi"; +import { + expectBulkRowAccepted, + expectBulkRowError, + expectNotenError, + expectNotenSuccess, +} from "../../../../support/helpers/notenErrors"; +import { requireConfig, requireNotenMode, requireWiederholung, skipIf } from "../../../../support/helpers/notenConfig"; +import { + attemptDate, + baselineDate, + loadNotenContext, + requireDbReset, + resetNotenState, + seedBaseline, + shiftDate, +} from "../../../../support/helpers/notenTestData"; +import { + addPruefung, + attemptsOfStudent, + editPruefung, + givenBaseline, + readStateViaApi, +} from "../../../../support/helpers/notenScenario"; + +describe("Noten API - konfigurierbare Terminregeln", () => { + let ctx; + + before(() => { + loadNotenContext().then((context) => { + ctx = context; + cy.log(`TERMIN_GLEICHER_TAG = ${ctx.cisConfig.CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG}`); + cy.log( + `NOTE_SPERRE_BEI_SPAETEREM_TERMIN = ${ctx.cisConfig.CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN}`, + ); + cy.log(`DATUM_ZUKUNFT = ${ctx.cisConfig.CIS_GESAMTNOTE_DATUM_ZUKUNFT}`); + cy.log(`ANTRITT_MIN_ABSTAND_TAGE = ${ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE}`); + cy.log(`ANTRITT_MAX_ABSTAND_TAGE = ${ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE}`); + }); + }); + + const studentFor = (index) => { + const student = ctx.students[index]; + expect(student, `student at index ${index}`).to.exist; + return student; + }; + + beforeEach(() => requireDbReset()); + + // The rules compare Y-m-d strings. A different format sorts the occurrences incorrectly. + describe("Prüfungsdatum", () => { + const impossibleDay = () => `${baselineDate(ctx).slice(0, 4)}-02-31`; + + [ + ["einen Kalendertag, den es nicht gibt", impossibleDay], + ["das Format d.m.Y", () => attemptDate(ctx, 1).split("-").reverse().join(".")], + ["ein leeres Datum", () => ""], + ].forEach(([label, datum]) => { + it(`lehnt ${label} ab`, () => { + const student = studentFor(0); + + givenBaseline(ctx, student); + addPruefung(ctx, student, { note: ctx.notes.negativ, datum: datum() }).then((response) => + expectNotenError(response, "pruefungsdatumUngueltig"), + ); + + readStateViaApi(ctx).then((data) => { + expect(attemptsOfStudent(data, student.uid), "nur Antritt 1").to.have.length(1); + }); + }); + }); + + it("speichert ein Datum mit Uhrzeit als Kalendertag", function () { + requireWiederholung(this, ctx); + + const student = studentFor(1); + const datum = attemptDate(ctx, 1); + + givenBaseline(ctx, student); + addPruefung(ctx, student, { note: ctx.notes.negativ, datum: `${datum} 10:00:00` }).then((response) => + expectNotenSuccess(response, "Datum mit Uhrzeit"), + ); + + readStateViaApi(ctx).then((data) => { + const newTermin = attemptsOfStudent(data, student.uid)[1]; + expect(String(newTermin.datum).slice(0, 10), "der Kalendertag").to.eq(datum); + }); + }); + + it("lehnt im Import nur die Zeile mit dem ungültigen Datum ab", function () { + requireNotenMode(this, ctx); + requireWiederholung(this, ctx); + + const valid = studentFor(0); + const invalid = studentFor(1); + const bulkRow = (student, datum) => ({ + uid: student.uid, + note: ctx.notes.negativ, + punkte: null, + datum, + lehreinheit_id: student.lehreinheit_id, + }); + + resetNotenState(ctx); + seedBaseline(ctx, valid); + seedBaseline(ctx, invalid); + + notenApi + .savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [ + bulkRow(valid, attemptDate(ctx, 1)), + bulkRow(invalid, impossibleDay()), + ]) + .then((response) => { + const data = expectNotenSuccess(response, "savePruefungenBulk"); + expectBulkRowAccepted(data, valid.uid); + expectBulkRowError(data, invalid.uid, "pruefungsdatumUngueltig"); + }); + + readStateViaApi(ctx).then((data) => { + expect(attemptsOfStudent(data, invalid.uid), "nur Antritt 1").to.have.length(1); + }); + }); + }); + + // A second appointment on the same day: either too early or allowed as per config. + describe("CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", () => { + beforeEach(function () { + requireWiederholung(this, ctx); + }); + + // The initial start date is baselineDate, the new date falls exactly on that day + const terminOnBaselineDay = (student) => { + givenBaseline(ctx, student); + return addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: baselineDate(ctx) }); + }; + + it("lehnt einen Termin am selben Tag ab", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", false); + + terminOnBaselineDay(studentFor(0)).then((response) => + expectNotenError(response, "pruefungDatumBeforeExisting"), + ); + }); + + it("nimmt einen Termin am selben Tag an, wenn der Schalter an ist", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", true); + + terminOnBaselineDay(studentFor(0)).then((response) => expectNotenSuccess(response, "Termin am selben Tag")); + }); + + it("lehnt einen Termin vor einem bestehenden immer ab", () => { + const student = studentFor(1); + + givenBaseline(ctx, student); + + // One day earlier is too early in both configurations + addPruefung(ctx, student, { + note: ctx.gradeNotes[1], + datum: shiftDate(baselineDate(ctx), -1), + }).then((response) => expectNotenError(response, "pruefungDatumBeforeExisting")); + }); + }); + + // Does a later appointment block the earlier one? + describe("CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", () => { + beforeEach(function () { + requireWiederholung(this, ctx); + }); + + /** First performance and a later date. After that, the request only changes the score for the first performance. */ + const changeNoteOfAntritt1 = (student) => { + givenBaseline(ctx, student); + + return addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) }) + .then((response) => expectNotenSuccess(response, "späterer Termin")) + .then(() => readStateViaApi(ctx)) + .then((data) => { + const firstAntritt = attemptsOfStudent(data, student.uid)[0]; + expect(firstAntritt, "Antritt 1").to.exist; + + return editPruefung(ctx, student, { + pruefungId: firstAntritt.pruefung_id, + note: ctx.gradeNotes[1], + datum: firstAntritt.datum, + }); + }); + }; + + it("sperrt die Note eines früheren Termins", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", true); + + changeNoteOfAntritt1(studentFor(2)).then((response) => expectNotenError(response, "pruefungNoteLocked")); + }); + + it("lässt die Note eines früheren Termins ändern, wenn der Schalter aus ist", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", false); + + changeNoteOfAntritt1(studentFor(2)).then((response) => + expectNotenSuccess(response, "Note trotz späterem Termin"), + ); + }); + }); + + // The grading date is in the future. + describe("CIS_GESAMTNOTE_DATUM_ZUKUNFT", () => { + const uebernahmeWithTomorrow = (student) => { + const tomorrow = shiftDate(new Date().toISOString().slice(0, 10), 1); + + givenBaseline(ctx, student, { erstantritt: false }); + return notenApi.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0], null, tomorrow); + }; + + it("lehnt ein künftiges Benotungsdatum ab", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_DATUM_ZUKUNFT", false); + + uebernahmeWithTomorrow(studentFor(3)).then((response) => + expectNotenError(response, "benotungsdatumInZukunft"), + ); + }); + + it("nimmt ein künftiges Benotungsdatum an, wenn der Schalter an ist", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_DATUM_ZUKUNFT", true); + + uebernahmeWithTomorrow(studentFor(3)).then((response) => + expectNotenSuccess(response, "künftiges Benotungsdatum"), + ); + }); + }); + + // Die Wartefrist zwischen zwei Antritten. Ohne Wert ist die Regel aus. + describe("CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE", () => { + let min; + + beforeEach(function () { + min = Number(ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE); + skipIf( + this, + !Number.isFinite(min) || min <= 0, + "Übersprungen: CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE ist nicht gesetzt.", + ); + requireWiederholung(this, ctx); + }); + + it("lehnt einen Antritt vor Ablauf der Wartefrist ab", () => { + const student = studentFor(4); + + givenBaseline(ctx, student); + + addPruefung(ctx, student, { + note: ctx.gradeNotes[1], + datum: shiftDate(baselineDate(ctx), min - 1), + }).then((response) => expectNotenError(response, "pruefungAbstandZuKurz")); + }); + + it("nimmt einen Antritt genau am Ende der Wartefrist an", () => { + const student = studentFor(5); + + givenBaseline(ctx, student); + + addPruefung(ctx, student, { + note: ctx.gradeNotes[1], + datum: shiftDate(baselineDate(ctx), min), + }).then((response) => expectNotenSuccess(response, "Antritt am Ende der Wartefrist")); + }); + }); + + // Die Höchstfrist bis zum nächsten Antritt. + describe("CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE", () => { + let max; + + beforeEach(function () { + max = Number(ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE); + skipIf( + this, + !Number.isFinite(max) || max <= 0, + "Übersprungen: CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE ist nicht gesetzt.", + ); + requireWiederholung(this, ctx); + }); + + it("lehnt einen Antritt nach Ablauf der Höchstfrist ab", () => { + const student = studentFor(6); + + givenBaseline(ctx, student); + + addPruefung(ctx, student, { + note: ctx.gradeNotes[1], + datum: shiftDate(baselineDate(ctx), max + 1), + }).then((response) => expectNotenError(response, "pruefungAbstandZuLang")); + }); + }); +}); diff --git a/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js b/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js index 52db022b3..cf0315f7d 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js @@ -1,19 +1,14 @@ /** - * Overwrite-Regeln der Notenvorschlag-Spalte. + * Overwrite rules for the grade suggestion column. * - * Drei Regeln: die Editorliste bietet nur lehre-Noten an, die Spalte ist gesperrt, sobald eine - * Prüfung existiert, und eine Zeugnisnote mit lkt_ueberschreibbar = false sperrt sie ebenfalls. - * Alle drei stehen in Benotungstool.js UND in Noten::validateNotenvorschlag - der direkte - * API-Aufruf und der CSV-Import erreichen den Client nie. + * Three rules: the editor list only offers “lehre” noten, the column is locked as soon as an + * exam exists; and a report card grade with lkt_ueberschreibbar = false also locks it. + * All three are defined in Benotungstool.js AND in Noten::validateNotenvorschlag the direct + * API call and the CSV import never reach the client. */ import { expectBulkRowError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors"; -import { - requireKonfiguration, - requireNotenModus, - requireWiederholung, - skipWenn, -} from "../../../../support/helpers/notenConfig"; +import { requireConfig, requireNotenMode, requireWiederholung, skipIf } from "../../../../support/helpers/notenConfig"; import { attemptDate, loadNotenContext, @@ -43,17 +38,17 @@ describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => { const studentFor = (index) => ctx.students[index % ctx.students.length]; it("lehnt eine Note ab, die die Auswahlliste nicht anbietet", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN", true); - // auf dieser Instanz kann jede aktive Note eine Lehrenote sein - skipWenn(this, ctx.notes.nichtLehre === null, "Übersprungen: keine Verwaltungsnote in tbl_note."); + requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN", true); + // On this instance, any active note can be a lehre note + skipIf(this, ctx.notes.notLehre === null, "Übersprungen: keine Verwaltungsnote in tbl_note."); const student = studentFor(0); - // ohne Erstantritt: sonst greift die Prüfungsregel und die Notenregel bliebe ungeprüft + // without a first attempt: otherwise, the review rule applies and the grading rule would remain unreviewed givenBaseline(ctx, student, { erstantritt: false }); notenApi - .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notes.nichtLehre) + .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notes.notLehre) .then((response) => expectNotenError(response, "c4noteNichtInLehre")) .then(() => readLvGesamtnoteViaDb(ctx, student.uid)) .then((row) => { @@ -86,12 +81,12 @@ describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => { it("lehnt das Überschreiben einer gesperrten Zeugnisnote ab", function () { // auf dieser Instanz darf die Lehrperson jede aktive Note überschreiben - skipWenn(this, ctx.notes.nichtUeberschreibbar === null, "Übersprungen: keine gesperrte Note in tbl_note."); + skipIf(this, ctx.notes.notUeberschreibbar === null, "Übersprungen: keine gesperrte Note in tbl_note."); const student = studentFor(4); givenBaseline(ctx, student, { erstantritt: false }); - seedZeugnisnote(ctx, student.uid, ctx.notes.nichtUeberschreibbar); + seedZeugnisnote(ctx, student.uid, ctx.notes.notUeberschreibbar); notenApi .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[1]) @@ -102,35 +97,35 @@ describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => { }); }); - // W1: Ein Termin schreibt dieselbe LV-Note wie die Übernahme und prüft dieselben Notenregeln. + // Ein Termin schreibt dieselbe LV-Note wie die Übernahme und prüft dieselben Notenregeln. describe("Prüfungspfade", () => { /** Eine Verwaltungsnote, die einen Antritt verbraucht. Noten ohne Antritt bleiben erlaubt. */ - const skipOhneVerwaltungsnote = (test) => { - requireKonfiguration(test, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN", true); + const skipWithoutVerwaltungsnote = (test) => { + requireConfig(test, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN", true); - const ohneAntritt = (ctx.cisConfig.NOTEN_OHNE_ANTRITT || []).map(String); - const brauchbar = ctx.notes.nichtLehre !== null && !ohneAntritt.includes(String(ctx.notes.nichtLehre)); + const withoutAntritt = (ctx.cisConfig.NOTEN_OHNE_ANTRITT || []).map(String); + const usable = ctx.notes.notLehre !== null && !withoutAntritt.includes(String(ctx.notes.notLehre)); - skipWenn(test, !brauchbar, "Übersprungen: keine Verwaltungsnote, die einen Antritt verbraucht."); + skipIf(test, !usable, "Übersprungen: keine Verwaltungsnote, die einen Antritt verbraucht."); }; - const expectNurAntritt1 = (student) => + const expectOnlyAntritt1 = (student) => readStateViaApi(ctx).then((data) => { expect(attemptsOfStudent(data, student.uid), "nur Antritt 1").to.have.length(1); }); it("lehnt einen Termin mit einer Verwaltungsnote ab", function () { - skipOhneVerwaltungsnote(this); + skipWithoutVerwaltungsnote(this); requireWiederholung(this, ctx); const student = studentFor(0); givenBaseline(ctx, student); - addPruefung(ctx, student, { note: ctx.notes.nichtLehre, datum: attemptDate(ctx, 1) }).then((response) => + addPruefung(ctx, student, { note: ctx.notes.notLehre, datum: attemptDate(ctx, 1) }).then((response) => expectNotenError(response, "c4noteNichtInLehre"), ); - expectNurAntritt1(student); + expectOnlyAntritt1(student); readLvGesamtnoteViaDb(ctx, student.uid).then((row) => { expect(String(row.note), "die LV-Note bleibt").to.eq(String(ctx.notes.negativ)); }); @@ -139,27 +134,27 @@ describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => { it("lehnt einen Termin bei gesperrter Zeugnisnote ab", function () { requireWiederholung(this, ctx); - // eine Anrechnungsnote meldet c4angerechnetKeinePruefung, deshalb schliesst der Test sie aus + // A anrechnungs grade reports “c4angerechnetKeinePruefung,” so the test excludes it const anrechnung = (ctx.cisConfig.NOTEN_ANRECHNUNG || []).map(String); - const gesperrt = ctx.notenOptions.find( + const locked = ctx.notenOptions.find( (n) => n.lkt_ueberschreibbar === false && !anrechnung.includes(String(n.note)), ); - skipWenn(this, !gesperrt, "Übersprungen: keine gesperrte Note ausser den Anrechnungsnoten."); + skipIf(this, !locked, "Übersprungen: keine gesperrte Note ausser den Anrechnungsnoten."); const student = studentFor(1); givenBaseline(ctx, student); - seedZeugnisnote(ctx, student.uid, gesperrt.note); + seedZeugnisnote(ctx, student.uid, locked.note); addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) }).then((response) => expectNotenError(response, "c4zeugnisnoteGesperrt"), ); - expectNurAntritt1(student); + expectOnlyAntritt1(student); }); it("lehnt im Sammeldialog jede Zeile mit einer Verwaltungsnote ab", function () { - skipOhneVerwaltungsnote(this); + skipWithoutVerwaltungsnote(this); const students = [studentFor(0), studentFor(1)]; @@ -171,7 +166,7 @@ describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => { attemptDate(ctx, 1), ctx.lvId, ctx.semKurzbz, - ctx.notes.nichtLehre, + ctx.notes.notLehre, ) .then((response) => { const data = expectNotenSuccess(response, "createPruefungen mit einer Verwaltungsnote"); @@ -186,13 +181,13 @@ describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => { }); }); - // Derselbe Weg über den CSV-Import, den eine Assistenz tatsächlich fährt. Der Bulk-Endpunkt - // antwortet 200 und meldet die abgelehnte Zeile in data[uid]. + // The same process via CSV import that an assistant actually follows. The bulk endpoint + // returns a 200 status code and reports the rejected row in data[uid]. describe("saveNotenvorschlagBulk", () => { beforeEach(function () { - // im Punktemodus leitet der Import die Note aus den Punkten ab und verwirft eine - // Zeile ohne Punkte, bevor eine dieser Regeln greift - requireNotenModus(this, ctx); + // In point mode, the import calculates the grade based on the points and discards a + // row with no points before any of these rules take effect + requireNotenMode(this, ctx); requireWiederholung(this, ctx); }); diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.ablehnung.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.ablehnung.cy.js new file mode 100644 index 000000000..8be9643d5 --- /dev/null +++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.ablehnung.cy.js @@ -0,0 +1,139 @@ +import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po"; +import { requireConfig, requireNotenMode, requireWiederholung } from "../../../../support/helpers/notenConfig"; +import { + attemptDate, + loadNotenContext, + requireDbReset, + resetNotenState, + seedBaseline, + seedPruefung, +} from "../../../../support/helpers/notenTestData"; + +/** + * The server rejects the save, and the interface displays this. + * + * Each test loads the table and then modifies the database. The table is therefore out of date, as if + * an entry had been made in a second window. The client checks against the old state and sends the request; + * only then does the server reject it. The test reads the message from the response and expects exactly that message in + * a toast notification. It therefore does not hard-code any phrase and runs in every profile. + */ +context("Benotungstool UI - Ablehnung durch den Server", () => { + let ctx; + let bezeichnung; + + before(() => { + requireDbReset(); + loadNotenContext().then((context) => { + ctx = context; + bezeichnung = (note) => page.bezeichnungOf(ctx, note); + }); + }); + + beforeEach(function () { + // im Punktemodus sind die Notenfelder gesperrt, die Note kommt aus den Punkten + requireNotenMode(this, ctx); + }); + + /** An excused termin after the date entered in the test. It does not count toward the attendance limit. */ + const laterTerminInBackground = (student) => + seedPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 3), type: "Termin2" }); + + it("zeigt die Ablehnung eines Termins aus dem Zelldialog", function () { + requireWiederholung(this, ctx); + + const student = ctx.students[0]; + + resetNotenState(ctx); + seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: true }); + page.visitAndWaitForTable(ctx); + laterTerminInBackground(student); + + page.submitPruefungInCell(student.uid, "antritt_2", { + note: bezeichnung(ctx.notes.negativ), + datum: page.toDDMMYYYY(attemptDate(ctx, 1)), + }); + + page.expectRejected("@saveStudentPruefung"); + page.expectNoPruefung(student.uid, "antritt_2"); + }); + + it("zeigt die Ablehnung einer Übernahme und behält die LV-Note", () => { + const student = ctx.students[1]; + + resetNotenState(ctx); + seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: false, erstantritt: false }); + page.visitAndWaitForTable(ctx); + + // A duplicate entry prevents the update; the table is unaware of this + seedPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1), type: "Termin2" }); + + // “Sehr Gut” first: setNotenvorschlag uses the “contains” operator; ‘Gut’ would match “Sehr Gut” + page.setNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0])); + page.submitUebernahme(student.uid); + + page.expectRejected("@saveNotenvorschlag"); + page.expectLvNote(student.uid, bezeichnung(ctx.notes.negativ)); + }); + + it("zeigt die abgelehnte Zeile der Sammelanlage und meldet keinen Erfolg", function () { + requireWiederholung(this, ctx); + + const student = ctx.students[2]; + + resetNotenState(ctx); + seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: true }); + page.visitAndWaitForTable(ctx); + laterTerminInBackground(student); + + page.submitPruefungBulk({ + uids: [student.uid], + note: bezeichnung(ctx.notes.negativ), + datum: page.toDDMMYYYY(attemptDate(ctx, 1)), + }); + + page.expectRowRejected("@createPruefungen", student.uid); + page.expectNoSuccess(); + page.expectNoPruefung(student.uid, "antritt_2"); + }); + + it("zeigt die abgelehnte Zeile des Prüfungsimports und meldet keinen Erfolg", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true); + requireWiederholung(this, ctx); + + const student = ctx.students[3]; + + resetNotenState(ctx); + seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: true }); + page.visitAndWaitForTable(ctx); + laterTerminInBackground(student); + + page.submitPruefungImport([ + [ + student.uid, + page.importDate(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT), + ctx.notes.negativ, + ], + ]); + + page.expectRowRejected("@savePruefungenBulk", student.uid); + page.expectNoSuccess(); + page.expectNoPruefung(student.uid, "antritt_2"); + }); + + it("zeigt die abgelehnte Zeile des Notenimports und meldet keinen Erfolg", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true); + + const student = ctx.students[4]; + + resetNotenState(ctx); + seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: false, erstantritt: false }); + page.visitAndWaitForTable(ctx); + seedPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1), type: "Termin2" }); + + page.submitNotenImport([[student.uid, ctx.gradeNotes[0]]]); + + page.expectRowRejected("@saveNotenvorschlagBulk", student.uid); + page.expectNoSuccess(); + page.expectLvNote(student.uid, bezeichnung(ctx.notes.negativ)); + }); +}); diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.freigabe.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.freigabe.cy.js index 1f7301c59..75ea0f31b 100644 --- a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.freigabe.cy.js +++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.freigabe.cy.js @@ -1,5 +1,6 @@ +import { notenAuth } from "../../../../support/api/notenApi"; import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po"; -import { requireKonfiguration } from "../../../../support/helpers/notenConfig"; +import { requireConfig } from "../../../../support/helpers/notenConfig"; import { baselineDate, loadNotenContext, @@ -10,15 +11,15 @@ import { } from "../../../../support/helpers/notenTestData"; /** - * Notenfreigabe über den Modal-Dialog mit Passwort. + * Approve grades via the modal dialog using a password. * - * Der Dialog listet genau die Zeilen, die freigegeben werden (changedNoten), und verlangt das - * LDAP-Passwort. Danach muss die Statusspalte ohne Reload von changed auf ok springen. + * The dialog lists exactly which rows are being released (changedNoten) and prompts for the + * LDAP password. Afterward, the status column must change from “changed” to “ok” without a reload. * - * Eine erfolgreiche Freigabe verschickt die Freigabemail. Die Entwicklungsinstanzen stellen sie in ein - * Debug-Postfach zu. + * A successful release triggers the release email. The development instances deliver it to a + * debug mailbox. */ -const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || Cypress.env("adminpassword"); +const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || notenAuth().password; context("Benotungstool UI - Notenfreigabe", () => { let ctx; @@ -47,8 +48,8 @@ context("Benotungstool UI - Notenfreigabe", () => { }); it("lehnt ein falsches Passwort ab und lässt den Status unverändert", function () { - // ohne Passwortpflicht gibt der Dialog frei - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", true); + // If no password is required, the dialog box allows access + requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", true); const student = ctx.students[0]; @@ -58,11 +59,11 @@ context("Benotungstool UI - Notenfreigabe", () => { page.visitAndWaitForTable(ctx); page.openFreigabeModal(); - page.typeFreigabePasswort("definitely-not-the-password"); + page.typeFreigabePassword("definitely-not-the-password"); page.submitFreigabe(); - // die Oberfläche zeigt die Ablehnung, der Status bleibt - page.expectAbgelehnt("@saveStudentenNoten"); + // The interface displays the rejection, but the status remains the same + page.expectRejected("@saveStudentenNoten"); page.expectFreigabeState(student.uid, "changed"); readLvGesamtnoteViaDb(ctx, student.uid).then((rowData) => { @@ -89,7 +90,7 @@ context("Benotungstool UI - Notenfreigabe", () => { }); it("legt mit der Freigabe den ersten Antritt an", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true); const student = ctx.students[1]; @@ -100,12 +101,12 @@ context("Benotungstool UI - Notenfreigabe", () => { page.openFreigabeModal(); page.freigeben(freigabePassword()); - // upsertErstantritt schreibt die Prüfungszeile; sichtbar wird sie beim nächsten Laden + // upsertErstantritt writes the audit entry; it becomes visible the next time the page is loaded page.visitAndWaitForTable(ctx); page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 }); page.expectAntrittCount(student.uid, 1); - // der Antritt übernimmt das Benotungsdatum der LV-Note, kein implizites Datum + // The start date uses the grading date of the course grade; no implicit date is used page.getCell(student.uid, "antritt_1").should("contain.text", page.toDDMMYYYY(baselineDate(ctx))); }); }); diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.import.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.import.cy.js index 84752c126..23ee84723 100644 --- a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.import.cy.js +++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.import.cy.js @@ -1,10 +1,5 @@ import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po"; -import { - requireKonfiguration, - requireNotenModus, - requireWiederholung, - skipWenn, -} from "../../../../support/helpers/notenConfig"; +import { requireConfig, requireNotenMode, requireWiederholung, skipIf } from "../../../../support/helpers/notenConfig"; import { attemptDate, loadNotenContext, @@ -14,13 +9,13 @@ import { } from "../../../../support/helpers/notenTestData"; /** - * Die beiden Importe aus der Toolbar. + * The two import options. * - * Notenimport: "UIDNote" je Zeile, schreibt nur die LV-Note. - * Prüfungsimport: "UIDdd.MM.yyyyNote" je Zeile, legt zusätzlich den Termin an. + * Grade import: “UIDGrade” per line; writes only the course grade. + * Exam import: “UIDdd.MM.yyyyGrade” per line; also creates the exam date. * - * Beide sind über CIS_GESAMTNOTE_NOTENIMPORT / CIS_GESAMTNOTE_PRUEFUNGSIMPORT konfigurierbar - ist - * der Import aus, existiert der Button nicht und der Block wird übersprungen. + * Both can be configured via CIS_GESAMTNOTE_NOTENIMPORT / CIS_GESAMTNOTE_PRUEFUNGSIMPORT + * if the import is disabled, the button does not appear and the block is skipped. */ context("Benotungstool UI - Import", () => { let ctx; @@ -36,36 +31,36 @@ context("Benotungstool UI - Import", () => { beforeEach(function () { // im Punktemodus erwarten beide Importe Punkte statt einer Note - requireNotenModus(this, ctx); + requireNotenMode(this, ctx); }); /** - * Eine Note, deren Kürzel aus tbl_note.anmerkung im Import brauchbar ist: eindeutig, nicht leer - * und nicht die Note selbst. Passt die Konfiguration nicht zum Parameter, kommt null zurück und - * der Test überspringt sich. Nichts davon steht fest im Test, die Spalte ist Freitext. + * A grade whose abbreviation from tbl_note.anmerkung can be used in the import: unique, not empty, + * and not the grade itself. If the configuration does not match the parameter, null is returned and + * the test skips this line. */ const kuerzelNote = () => { if (!ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL) return null; - const kuerzelVon = (n) => + const kuerzelOf = (n) => String(n.anmerkung ?? "") .trim() .toLowerCase(); return (ctx.notenOptions ?? []).find((n) => { - const k = kuerzelVon(n); + const k = kuerzelOf(n); if (!n.lehre || k === "" || k === String(n.note).trim()) return false; - const doppelt = ctx.notenOptions.filter((o) => kuerzelVon(o) === k).length > 1; - const alsNote = ctx.notenOptions.some((o) => String(o.note).trim() === k); + const duplicate = ctx.notenOptions.filter((o) => kuerzelOf(o) === k).length > 1; + const isNoteValue = ctx.notenOptions.some((o) => String(o.note).trim() === k); - return !doppelt && !alsNote; + return !duplicate && !isNoteValue; }); }; describe("Notenimport", () => { beforeEach(function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true); }); it("schreibt die LV-Note für jede Zeile", () => { @@ -88,7 +83,7 @@ context("Benotungstool UI - Import", () => { }); it("legt dabei den ersten Antritt an", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true); const student = ctx.students[0]; @@ -97,14 +92,14 @@ context("Benotungstool UI - Import", () => { page.importNoten([[student.uid, ctx.gradeNotes[0]]]); - // die LV-Note IST Antritt 1, der Import schreibt ihn als eigene Zeile + // The course grade IS “Attendance 1”; the import writes it as a separate row page.expectAntrittCount(student.uid, 1); page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 }); }); it("nimmt das Kürzel aus der Notenliste, sobald die Option das erlaubt", function () { const kuerzel = kuerzelNote(); - skipWenn(this, !kuerzel, "Übersprungen: keine Note mit brauchbarem Kürzel in tbl_note.anmerkung."); + skipIf(this, !kuerzel, "Übersprungen: keine Note mit brauchbarem Kürzel in tbl_note.anmerkung."); const student = ctx.students[1]; @@ -119,7 +114,7 @@ context("Benotungstool UI - Import", () => { describe("Prüfungsimport", () => { beforeEach(function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true); }); it("legt je Zeile einen datierten Antritt an", function () { @@ -127,7 +122,7 @@ context("Benotungstool UI - Import", () => { const [a, b] = ctx.students; // das Format nennt die Konfiguration, nicht der Test - const datum = page.importDatum(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT); + const datum = page.importDate(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT); resetNotenState(ctx); seedBaseline(ctx, a, { note: ctx.notes.negativ }); @@ -156,7 +151,7 @@ context("Benotungstool UI - Import", () => { page.importPruefungen([ [ student.uid, - page.importDatum(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT), + page.importDate(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT), ctx.gradeNotes[0], ], ]); @@ -166,18 +161,18 @@ context("Benotungstool UI - Import", () => { }); }); - // Der Client prüft jede Zeile, bevor er sie schickt. Eine fehlerhafte Zeile meldet er als Warnung und - // schickt nur die übrigen. Doppelte Zeilen erkennt er bewusst nicht. + // The client checks each line before sending it. It reports any invalid lines as a warning and + // sends only the remaining ones. It deliberately does not detect duplicate lines. describe("fehlerhafte Zeilen", () => { it("warnt im Prüfungsimport je fehlerhafter Zeile und schickt nur die gültigen", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true); requireWiederholung(this, ctx); - const [perUid, perMatrikelnr, falschesDatum, falscheNote, falscheSpalten] = ctx.students; + const [perUid, perMatrikelnr, wrongDate, wrongNote, wrongColumns] = ctx.students; const format = ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT; - const datum = page.importDatum(attemptDate(ctx, 1), format); - const jahr = attemptDate(ctx, 1).slice(0, 4); - const unmoeglich = format === "yyyy-MM-dd" ? `${jahr}-02-31` : `31.02.${jahr}`; + const datum = page.importDate(attemptDate(ctx, 1), format); + const year = attemptDate(ctx, 1).slice(0, 4); + const impossible = format === "yyyy-MM-dd" ? `${year}-02-31` : `31.02.${year}`; const note = ctx.notes.negativ; expect(perMatrikelnr.matrikelnr, "Matrikelnummer des Studierenden").to.be.a("string").and.not.be.empty; @@ -191,18 +186,18 @@ context("Benotungstool UI - Import", () => { [perUid.uid, datum, note], [perMatrikelnr.matrikelnr, datum, note], ["zz_unbekannt", datum, note], - [falschesDatum.uid, unmoeglich, note], - [falscheNote.uid, datum, "keine-note"], - [falscheSpalten.uid, datum], + [wrongDate.uid, impossible, note], + [wrongNote.uid, datum, "keine-note"], + [wrongColumns.uid, datum], ]); - page.expectWarnung("zz_unbekannt"); - page.expectWarnung(falschesDatum.uid); - page.expectWarnung(falscheNote.uid); + page.expectWarning("zz_unbekannt"); + page.expectWarning(wrongDate.uid); + page.expectWarning(wrongNote.uid); // die Zeile mit falscher Spaltenzahl nennt nur ihre Zeilennummer - page.expectWarnungen(4); + page.expectWarnings(4); - page.gesendeteUids("@savePruefungenBulk", "pruefungen").then((uids) => { + page.sentUids("@savePruefungenBulk", "pruefungen").then((uids) => { expect(uids, "nur die gültigen Zeilen, die Matrikelnummer aufgelöst").to.deep.eq([ perUid.uid, perMatrikelnr.uid, @@ -211,26 +206,26 @@ context("Benotungstool UI - Import", () => { }); it("warnt im Notenimport je fehlerhafter Zeile und schickt nur die gültigen", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true); - const [gueltig, falscheNote, falscheSpalten] = ctx.students; + const [valid, wrongNote, wrongColumns] = ctx.students; resetNotenState(ctx); page.visitAndWaitForTable(ctx); page.importNoten([ - [gueltig.uid, ctx.gradeNotes[0]], + [valid.uid, ctx.gradeNotes[0]], ["zz_unbekannt", ctx.gradeNotes[0]], - [falscheNote.uid, "keine-note"], - [falscheSpalten.uid, ctx.gradeNotes[0], "zu viel"], + [wrongNote.uid, "keine-note"], + [wrongColumns.uid, ctx.gradeNotes[0], "zu viel"], ]); - page.expectWarnung("zz_unbekannt"); - page.expectWarnung(falscheNote.uid); - page.expectWarnungen(3); + page.expectWarning("zz_unbekannt"); + page.expectWarning(wrongNote.uid); + page.expectWarnings(3); - page.gesendeteUids("@saveNotenvorschlagBulk", "noten").then((uids) => { - expect(uids, "nur die gültige Zeile").to.deep.eq([gueltig.uid]); + page.sentUids("@saveNotenvorschlagBulk", "noten").then((uids) => { + expect(uids, "nur die gültige Zeile").to.deep.eq([valid.uid]); }); }); }); diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.navigation.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.navigation.cy.js new file mode 100644 index 000000000..1c0677408 --- /dev/null +++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.navigation.cy.js @@ -0,0 +1,78 @@ +import { notenAuth } from "../../../../support/api/notenApi"; +import { waitForOk } from "../../../../support/helpers/network"; +import { + assistenzAuth, + assistenzLvWithLehreinheiten, + requireAssistenz, +} from "../../../../support/helpers/notenAssistenz"; +import { loadNotenContext, requireDbReset } from "../../../../support/helpers/notenTestData"; + +/** + * The route is /Cis/Benotungstool/:sem_kurzbz?/:lv_id?. The semester comes before the course, so + * the router no longer interprets a URL without a course as lv_id. + */ +context("Benotungstool UI - Navigation", () => { + const SEMESTER = "[data-cy='dropdown-semester']"; + const TIMEOUT = 60_000; + + /** The text that appears in the closed dropdown once the component has selected a semester. */ + const selectedSemester = () => + cy + .get(`${SEMESTER} .p-dropdown-label`, { timeout: TIMEOUT }) + .should(($label) => expect($label.text().trim(), "vorausgewähltes Semester").to.not.be.empty) + .then(($label) => $label.text().trim()); + + it("behält ein Semester ohne LV nach einem Reload", () => { + const { username, password } = notenAuth(); + cy.login(username, password); + cy.intercept({ method: "GET", url: "**/api/frontend/v1/Noten/getBenotungstoolContext*" }).as("context"); + cy.visit("/cis.php/Cis/Benotungstool"); + + selectedSemester() + .then((preselected) => { + cy.get(SEMESTER).click(); + return cy + .get(".p-dropdown-panel .p-dropdown-item") + .filter((i, el) => el.innerText.trim() !== preselected) + .first(); + }) + .then(($item) => { + const sem = $item.text().trim(); + + cy.wrap($item).click(); + waitForOk("@context"); + cy.location("pathname").should("match", new RegExp(`/Benotungstool/${sem}$`)); + + cy.reload(); + + selectedSemester().should("eq", sem); + cy.get(".p-toast-message-error").should("not.exist"); + }); + }); + + // The list contained only the courses that the caller teaches. + it("zeigt einer Assistenz die Lehreinheiten der LV", function () { + requireAssistenz(this); + + const { username, password } = assistenzAuth(); + + requireDbReset(); + loadNotenContext().then((ctx) => { + cy.clearAllCookies(); + + assistenzLvWithLehreinheiten(ctx.semKurzbz).then((target) => { + expect(target, "eine LV der Assistenz mit Lehreinheiten").to.not.be.null; + + cy.login(username, password); + cy.intercept({ method: "GET", url: "**/api/frontend/v1/Noten/getLehreinheitenFuerLv*" }).as( + "lehreinheiten", + ); + cy.visit(`/cis.php/Cis/Benotungstool/${target.sem}/${target.lvId}`); + + waitForOk("@lehreinheiten"); + cy.get("[data-cy='dropdown-lehreinheit']", { timeout: TIMEOUT }).click(); + cy.get(".p-dropdown-panel .p-dropdown-item").should("have.length", target.lehreinheiten.length); + }); + }); + }); +}); diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.notenvorschlag.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.notenvorschlag.cy.js index 29a7201f3..8d627ac52 100644 --- a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.notenvorschlag.cy.js +++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.notenvorschlag.cy.js @@ -1,5 +1,5 @@ import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po"; -import { requireKonfiguration, requireNotenModus } from "../../../../support/helpers/notenConfig"; +import { requireConfig, requireNotenMode } from "../../../../support/helpers/notenConfig"; import { attemptDate, baselineDate, @@ -12,11 +12,11 @@ import { } from "../../../../support/helpers/notenTestData"; /** - * Notenvorschlag eintragen und übernehmen. + * Enter and apply the grade suggestion. * - * Die Regeln dahinter prüft noten.notenvorschlag; hier geht es darum, dass die Tabelle den - * Serverzustand ohne Reload korrekt nachführt: LV-Note, Freigabestatus und das Verschwinden des - * Übernehmen-Buttons. + * The rules behind this are checked by noten.notenvorschlag; the goal here is to ensure that the table + * correctly updates the server state without a reload: course grade, approval status, and the disappearance of the + * “Übernehmen” button. */ context("Benotungstool UI - Notenvorschlag", () => { let ctx; @@ -32,7 +32,7 @@ context("Benotungstool UI - Notenvorschlag", () => { beforeEach(function () { // im Punktemodus ist die Vorschlagsspalte gesperrt, die Note kommt aus den Punkten - requireNotenModus(this, ctx); + requireNotenMode(this, ctx); }); it("trägt einen Vorschlag über den Zelleneditor ein", () => { @@ -67,7 +67,7 @@ context("Benotungstool UI - Notenvorschlag", () => { // Das gewählte Datum ist das Datum von Antritt 1, nicht das benotungsdatum. Das benotungsdatum // bleibt der Zeitpunkt der Eingabe, sonst erschiene eine geänderte Note als freigegeben. it("schreibt das im Dialog gewählte Datum in den ersten Antritt", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true); const student = ctx.students[2]; const datum = baselineDate(ctx); @@ -88,11 +88,11 @@ context("Benotungstool UI - Notenvorschlag", () => { }); }); - // W7: Eine einzelne Wiederholung ohne Antritt 1, wie sie die Studierendenverwaltung hinterlassen kann. Der + // Eine einzelne Wiederholung ohne Antritt 1, wie sie die Studierendenverwaltung hinterlassen kann. Der // Button fehlt auch ohne Vorschlag, deshalb setzt der Test einen Vorschlag und prüft eine Kontrollzeile mit. it("zeigt bei einer einzelnen Wiederholung keinen Übernehmen-Button", () => { const wiederholung = ctx.students[3]; - const kontrolle = ctx.students[4]; + const control = ctx.students[4]; // Sehr Gut zuerst: setNotenvorschlag vergleicht mit contains, "Gut" träfe "Sehr Gut" const vorschlag = ctx.gradeNotes[0]; const pruefungsnote = ctx.gradeNotes.find( @@ -101,19 +101,19 @@ context("Benotungstool UI - Notenvorschlag", () => { resetNotenState(ctx); seedBaseline(ctx, wiederholung, { erstantritt: false }); - seedBaseline(ctx, kontrolle); - seedPruefung(ctx, wiederholung, { note: pruefungsnote, datum: attemptDate(ctx, 1), typ: "Termin2" }); + seedBaseline(ctx, control); + seedPruefung(ctx, wiederholung, { note: pruefungsnote, datum: attemptDate(ctx, 1), type: "Termin2" }); page.visitAndWaitForTable(ctx); - page.setNotenvorschlag(kontrolle.uid, bezeichnung(vorschlag)); - page.getUebernehmenButton(kontrolle.uid).should("exist"); + page.setNotenvorschlag(control.uid, bezeichnung(vorschlag)); + page.getUebernehmenButton(control.uid).should("exist"); page.setNotenvorschlag(wiederholung.uid, bezeichnung(vorschlag)); page.getUebernehmenButton(wiederholung.uid).should("not.exist"); }); - // C4: Die LV-Note ist nie 'entschuldigt', der Editor bietet die Note deshalb nicht an. + // Die LV-Note ist nie 'entschuldigt', der Editor bietet die Note deshalb nicht an. it("bietet 'entschuldigt' nicht als Notenvorschlag an", () => { const student = ctx.students[1]; diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.pruefung.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.pruefung.cy.js index 299863d84..e0bac1a76 100644 --- a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.pruefung.cy.js +++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.pruefung.cy.js @@ -2,13 +2,13 @@ import { benotungstoolPage as page } from "../../../../support/pages/benotungsto import { waitForOk } from "../../../../support/helpers/network"; import { requireKommissionellerAntritt, - requireKonfiguration, - requireNotenModus, + requireConfig, + requireNotenMode, requireWiederholung, - skipWenn, + skipIf, } from "../../../../support/helpers/notenConfig"; import { expectNotenSuccess } from "../../../../support/helpers/notenErrors"; -import { addPruefung } from "../../../../support/helpers/notenScenario"; +import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario"; import { attemptDate, loadNotenContext, @@ -19,12 +19,12 @@ import { } from "../../../../support/helpers/notenTestData"; /** - * Prüfungen anlegen und bearbeiten, über beide Wege: den Dialog aus der Tabellenzelle und die - * Sammelanlage aus der Toolbar. Beide laufen serverseitig durch denselben Kern - * (siehe noten.pruefungstermin); geprüft wird, dass die Zelle danach ohne Reload stimmt. + * Create and edit exams using both methods: the dialog from the table cell and the + * batch creation from import. Both run on the server side using the same core + * (see noten.pruefungstermin); the system verifies that the cell is correct afterward without reloading. * - * Die Specs laufen im Terminmodus (Schaltfläche "Termine"), weil die Spaltennamen dort stabil sind: - * antritt_1, antritt_2, ... Im Datumsmodus heisst die Spalte wie das Prüfungsdatum. + * The specs run in schedule mode (the “Schedules” button) because the column names are consistent there: + * antritt_1, antritt_2, ... In date mode, the column is named after the exam date. */ context("Benotungstool UI - Prüfungen", () => { let ctx; @@ -42,7 +42,7 @@ context("Benotungstool UI - Prüfungen", () => { beforeEach(function () { // im Punktemodus ist das Notenfeld gesperrt, die Note kommt aus dem Notenschlüssel - requireNotenModus(this, ctx); + requireNotenMode(this, ctx); }); it("legt aus der Zelle eine Wiederholung an und zählt sie als Antritt 2", function () { @@ -68,11 +68,29 @@ context("Benotungstool UI - Prüfungen", () => { // die Zelle zeigt den Freigabestatus, den der Server geschrieben hat; die Regel prüft noten.pruefungstermin readLvGesamtnoteViaDb(ctx, student.uid).then((row) => { - const geaendert = new Date(row.benotungsdatum) > new Date(row.freigabedatum); - page.expectFreigabeState(student.uid, geaendert ? "changed" : "ok"); + const changed = new Date(row.benotungsdatum) > new Date(row.freigabedatum); + page.expectFreigabeState(student.uid, changed ? "changed" : "ok"); }); }); + it("nennt nach einer positiven Note den Grund statt der Schaltfläche", function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", false); + skipIf( + this, + !ctx.notes.positiv, + "Übersprungen: keine positive Note, die die Kette nur ohne Notenverbesserung schliesst.", + ); + + const student = ctx.students[0]; + + givenBaseline(ctx, student, { note: ctx.notes.positiv }); + + page.visitAndWaitForTable(ctx); + + page.getBestandenHint(student.uid, "antritt_2").should("exist").and("have.attr", "title").and("not.be.empty"); + page.getPruefungAddButton(student.uid, "antritt_2").should("not.exist"); + }); + it("legt einen Antritt für einen Studenten ohne LV-Note an und weist darauf hin", () => { const student = ctx.students[2]; @@ -95,7 +113,7 @@ context("Benotungstool UI - Prüfungen", () => { requireWiederholung(this, ctx); const student = ctx.students[0]; - const neuesDatum = attemptDate(ctx, 2); + const newDate = attemptDate(ctx, 2); resetNotenState(ctx); seedBaseline(ctx, student, { note: ctx.notes.negativ }); @@ -106,15 +124,15 @@ context("Benotungstool UI - Prüfungen", () => { note: bezeichnung(ctx.gradeNotes[1]), datum: page.toDDMMYYYY(attemptDate(ctx, 1)), }); - page.editPruefungInCell(student.uid, "antritt_2", { datum: page.toDDMMYYYY(neuesDatum) }); + page.editPruefungInCell(student.uid, "antritt_2", { datum: page.toDDMMYYYY(newDate) }); page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 }); - page.getCell(student.uid, "antritt_2").should("contain.text", page.toDDMMYYYY(neuesDatum)); + page.getCell(student.uid, "antritt_2").should("contain.text", page.toDDMMYYYY(newDate)); }); it("sperrt die Note, sobald ein späterer Antritt existiert", function () { requireWiederholung(this, ctx); - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", true); const student = ctx.students[0]; @@ -164,7 +182,7 @@ context("Benotungstool UI - Prüfungen", () => { }); }); - // W12: Der Sammeldialog prüft einen Termin am selben Tag wie der Server nach + // Der Sammeldialog prüft einen Termin am selben Tag wie der Server nach // CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG. describe("Termin am selben Tag im Sammeldialog", () => { beforeEach(function () { @@ -172,7 +190,7 @@ context("Benotungstool UI - Prüfungen", () => { }); // 'entschuldigt' verbraucht keinen Antritt, deshalb bleibt ein weiterer Termin möglich - const mitTerminAm = (student, datum) => { + const withTerminOn = (student, datum) => { resetNotenState(ctx); seedBaseline(ctx, student, { note: ctx.notes.negativ }); addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum }).then((response) => @@ -181,40 +199,40 @@ context("Benotungstool UI - Prüfungen", () => { page.visitAndWaitForTable(ctx); }; - const sammelterminAbschicken = (student, datum) => + const submitBulkTermin = (student, datum) => page.submitPruefungBulk({ uids: [student.uid], datum: page.toDDMMYYYY(datum) }); it("schickt einen Termin am selben Tag ab, wenn der Schalter an ist", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", true); + requireConfig(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", true); const student = ctx.students[0]; const datum = attemptDate(ctx, 1); - mitTerminAm(student, datum); - sammelterminAbschicken(student, datum); + withTerminOn(student, datum); + submitBulkTermin(student, datum); waitForOk("@createPruefungen"); cy.get(".p-toast-message-warn").should("not.exist"); }); it("warnt bei einem Termin am selben Tag und schickt nichts ab", function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", false); + requireConfig(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", false); const student = ctx.students[0]; const datum = attemptDate(ctx, 1); - mitTerminAm(student, datum); - sammelterminAbschicken(student, datum); + withTerminOn(student, datum); + submitBulkTermin(student, datum); cy.get(".p-toast-message-warn").should("contain.text", student.uid); - page.getNeuePruefungModal().should("not.be.visible"); + page.getNewPruefungModal().should("not.be.visible"); cy.get("@createPruefungen.all").should("have.length", 0); }); }); it("legt den letzten Antritt als kommissionelle Prüfung an", function () { requireKommissionellerAntritt(this, ctx); - skipWenn( + skipIf( this, ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT !== ctx.maxAntritte, "Übersprungen: der letzte Antritt ist nicht kommissionell.", @@ -237,21 +255,21 @@ context("Benotungstool UI - Prüfungen", () => { // Der letzte Antritt ist laut Prüfungsordnung kommissionell. Er bekommt keine eigene Spalte, // sondern den nächsten Prüfungstermin. Das K in der Zelle kennzeichnet ihn. - const letzterTermin = `antritt_${ctx.maxAntritte}`; + const lastTermin = `antritt_${ctx.maxAntritte}`; - page.getPruefungAddButton(student.uid, letzterTermin).should("exist"); + page.getPruefungAddButton(student.uid, lastTermin).should("exist"); - page.addPruefungInCell(student.uid, letzterTermin, { + page.addPruefungInCell(student.uid, lastTermin, { note: bezeichnung(ctx.notes.negativ), datum: page.toDDMMYYYY(attemptDate(ctx, ctx.maxAntritte)), }); page.expectAntrittCount(student.uid, ctx.maxAntritte); - // das Badge trägt beides: die Antrittsnummer und das K der kommissionellen Prüfung - page.expectPruefung(student.uid, letzterTermin, { + // The badge displays both the antritt number and the “K” for the commission exam + page.expectPruefung(student.uid, lastTermin, { note: ctx.notes.negativ, antritt: `${ctx.maxAntritte}-K`, }); - page.expectKeineAntrittsspalte(ctx.maxAntritte + 1); + page.expectNoAntrittColumn(ctx.maxAntritte + 1); }); }); diff --git a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.punkte.cy.js b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.punkte.cy.js index 3db49cbc1..8b176f216 100644 --- a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.punkte.cy.js +++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.punkte.cy.js @@ -1,5 +1,5 @@ import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po"; -import { requireKonfiguration, requirePunkteModus, requireWiederholung } from "../../../../support/helpers/notenConfig"; +import { requireConfig, requirePunkteMode, requireWiederholung } from "../../../../support/helpers/notenConfig"; import { attemptsOfStudent, readStateViaApi } from "../../../../support/helpers/notenScenario"; import { attemptDate, @@ -13,22 +13,22 @@ import { import { notenApi } from "../../../../support/api/notenApi"; /** - * Punktemodus (CIS_GESAMTNOTE_PUNKTE) in der Oberfläche. + * Point Mode (CIS_GESAMTNOTE_PUNKTE) in the user interface. * - * Die Punktespalte und die Punktefelder der beiden Dialoge existieren nur mit diesem Flag, die Note - * wird dann aus dem Notenschlüssel abgeleitet statt gewählt. Genau diese Felder deckt sonst nichts - * ab, deshalb steht der ganze Modus hier in einer eigenen Datei. + * The points column and the points fields in both dialogs exist only when this flag is set; the grade + * is then derived from the grading key instead of being selected. Nothing else covers these specific fields, + * which is why this entire mode is contained in its own file here. * - * Der Notenschlüssel wird nie hartcodiert: die erwartete Note kommt zur Laufzeit aus - * getNoteByPunkte, damit die Specs an jeder Instanz mit eigenem Schlüssel laufen. + * The grading key is never hard-coded: the expected grade is retrieved at runtime from + * getNoteByPunkte, so that the specs run with their own key for each instance. */ context("Benotungstool UI - Punktemodus", () => { let ctx; // Punktewerte, deren Noten zur Laufzeit ermittelt werden - const OBEN = 100; - const MITTE = 70; - let noteOben; - let noteMitte; + const TOP = 100; + const MIDDLE = 70; + let noteTop; + let noteMiddle; before(() => { requireDbReset(); @@ -36,22 +36,22 @@ context("Benotungstool UI - Punktemodus", () => { ctx = context; if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) return; - notenApi.getNoteByPunkte(OBEN, ctx.lvId, ctx.semKurzbz).then((r) => { - noteOben = r.body.data; - expect(noteOben, `${OBEN} Punkte müssen eine Note ergeben`).to.not.be.null; + notenApi.getNoteByPunkte(TOP, ctx.lvId, ctx.semKurzbz).then((r) => { + noteTop = r.body.data; + expect(noteTop, `${TOP} Punkte müssen eine Note ergeben`).to.not.be.null; }); - notenApi.getNoteByPunkte(MITTE, ctx.lvId, ctx.semKurzbz).then((r) => { - noteMitte = r.body.data; - expect(noteMitte, `${MITTE} Punkte müssen eine Note ergeben`).to.not.be.null; - expect(String(noteMitte), "die beiden Punktewerte müssen verschiedene Noten liefern").to.not.eq( - String(noteOben), + notenApi.getNoteByPunkte(MIDDLE, ctx.lvId, ctx.semKurzbz).then((r) => { + noteMiddle = r.body.data; + expect(noteMiddle, `${MIDDLE} Punkte müssen eine Note ergeben`).to.not.be.null; + expect(String(noteMiddle), "die beiden Punktewerte müssen verschiedene Noten liefern").to.not.eq( + String(noteTop), ); }); }); }); beforeEach(function () { - requirePunkteModus(this, ctx); + requirePunkteMode(this, ctx); }); describe("Punktespalte in der Tabelle", () => { @@ -63,7 +63,7 @@ context("Benotungstool UI - Punktemodus", () => { page.getPunkteCell(student.uid).should("exist"); // die Note kommt aus den Punkten, sie darf nicht direkt gewählt werden - page.expectNotenvorschlagGesperrt(student.uid); + page.expectNotenvorschlagLocked(student.uid); }); it("schreibt Note und Punkte, wenn der Vorschlag übernommen wird", () => { @@ -72,15 +72,15 @@ context("Benotungstool UI - Punktemodus", () => { resetNotenState(ctx); page.visitAndWaitForTable(ctx); - page.setPunkteInCell(student.uid, MITTE); + page.setPunkteInCell(student.uid, MIDDLE); page.uebernehmen(student.uid); - page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteMitte)); + page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteMiddle)); page.expectFreigabeState(student.uid, "changed"); readLvGesamtnoteViaDb(ctx, student.uid).then((row) => { - expect(String(row.note), "abgeleitete Note").to.eq(String(noteMitte)); - expect(Number(row.punkte), "die Punkte werden mitgeschrieben").to.eq(MITTE); + expect(String(row.note), "abgeleitete Note").to.eq(String(noteMiddle)); + expect(Number(row.punkte), "die Punkte werden mitgeschrieben").to.eq(MIDDLE); }); }); @@ -94,17 +94,16 @@ context("Benotungstool UI - Punktemodus", () => { seedPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1), - typ: "Termin2", + type: "Termin2", }); page.visitAndWaitForTable(ctx); - page.expectPunkteZelleGesperrt(student.uid); + page.expectPunkteCellLocked(student.uid); }); }); describe("Prüfungsdialog", () => { - // Beide Tests brauchen einen freien Antritt: eine positive Basisnote schliesst die Kette. it("bietet ein Punktefeld statt der Notenauswahl", () => { const student = ctx.students[1]; @@ -116,7 +115,7 @@ context("Benotungstool UI - Punktemodus", () => { page.getPruefungModal().should("be.visible"); cy.get("[data-cy='pruefung-punkte']").should("be.visible"); - page.expectNoteFeldGesperrt("pruefung-note"); + page.expectNoteFieldLocked("pruefung-note"); }); it("legt den Termin mit der aus den Punkten abgeleiteten Note an", () => { @@ -129,15 +128,15 @@ context("Benotungstool UI - Punktemodus", () => { page.getPruefungAddButton(student.uid, "antritt_2").click(); page.getPruefungModal().should("be.visible"); - page.setDatum("pruefung-datum", page.toDDMMYYYY(attemptDate(ctx, 1))); - page.setPruefungPunkte(OBEN); + page.setDate("pruefung-datum", page.toDDMMYYYY(attemptDate(ctx, 1))); + page.setPruefungPunkte(TOP); cy.get("[data-cy='pruefung-submit']").click(); cy.wait("@saveStudentPruefung").its("response.statusCode").should("eq", 200); page.getPruefungModal().should("not.be.visible"); - page.expectPruefung(student.uid, "antritt_2", { note: noteOben, antritt: 2 }); - page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteOben)); + page.expectPruefung(student.uid, "antritt_2", { note: noteTop, antritt: 2 }); + page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteTop)); }); }); @@ -153,9 +152,11 @@ context("Benotungstool UI - Punktemodus", () => { seedBaseline(ctx, b, { note: ctx.notes.negativ, freigegeben: true }); page.visitAndWaitForTable(ctx); - page.addPruefungBulk({ uids: [a.uid, b.uid], punkte: MITTE, datum: page.toDDMMYYYY(attemptDate(ctx, 1)) }); + page.addPruefungBulk({ uids: [a.uid, b.uid], punkte: MIDDLE, datum: page.toDDMMYYYY(attemptDate(ctx, 1)) }); - [a, b].forEach((student) => page.expectPruefung(student.uid, "antritt_2", { note: noteMitte, antritt: 2 })); + [a, b].forEach((student) => + page.expectPruefung(student.uid, "antritt_2", { note: noteMiddle, antritt: 2 }), + ); }); }); @@ -166,38 +167,38 @@ context("Benotungstool UI - Punktemodus", () => { [ ["Dezimalkomma", "89,5"], ["Dezimalpunkt", "89.5"], - ].forEach(([schreibweise, eingabe]) => { - it(`liest Punkte mit ${schreibweise} im Prüfungsimport`, function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true); + ].forEach(([notation, input]) => { + it(`liest Punkte mit ${notation} im Prüfungsimport`, function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true); requireWiederholung(this, ctx); const student = ctx.students[4]; - const datum = page.importDatum(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT); + const datum = page.importDate(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT); resetNotenState(ctx); seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: true }); page.visitAndWaitForTable(ctx); - page.importPruefungen([[student.uid, datum, eingabe]]); + page.importPruefungen([[student.uid, datum, input]]); cy.get("@savePruefungenBulk").its("request.body.pruefungen.0.punkte").should("eq", PUNKTE); readStateViaApi(ctx).then((data) => { - const neu = attemptsOfStudent(data, student.uid)[1]; - expect(neu, "der importierte Termin").to.exist; - expect(Number(neu.punkte), "die Punkte des Termins").to.eq(PUNKTE); + const newTermin = attemptsOfStudent(data, student.uid)[1]; + expect(newTermin, "der importierte Termin").to.exist; + expect(Number(newTermin.punkte), "die Punkte des Termins").to.eq(PUNKTE); }); }); - it(`liest Punkte mit ${schreibweise} im Notenimport`, function () { - requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true); + it(`liest Punkte mit ${notation} im Notenimport`, function () { + requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true); const student = ctx.students[5]; resetNotenState(ctx); page.visitAndWaitForTable(ctx); - page.importNoten([[student.uid, eingabe]]); + page.importNoten([[student.uid, input]]); cy.get("@saveNotenvorschlagBulk").its("request.body.noten.0.punkte").should("eq", PUNKTE); diff --git a/tests/cypress/e2e/specs/unit/notenRules.cy.js b/tests/cypress/e2e/specs/unit/notenRules.cy.js index f3f62573f..6b5e4e3d1 100644 --- a/tests/cypress/e2e/specs/unit/notenRules.cy.js +++ b/tests/cypress/e2e/specs/unit/notenRules.cy.js @@ -1,8 +1,8 @@ /** - * Die §1-Regeln auf Clientseite. Pure functions - kein Server, keine Fixture. + * The §1 rules on the client side as pure functions. * - * Die Regeln liegen im Server und erreichen den Client als `student.verlauf`; hier wird nur das - * Auswerten und der lokale Fallback (Verlauf noch nicht geladen) festgenagelt. + * The rules are stored on the server and are sent to the client as `student.history`; here, only the + * evaluation and the local fallback (history not yet loaded) are hard-coded. */ import { @@ -10,6 +10,7 @@ import { brauchtNeueLvNote, canAddPruefung, checkFreigabe, + isBestanden, maxAntrittCount, } from "../../../../../public/js/components/Cis/Benotungstool/notenRules.js"; @@ -47,21 +48,21 @@ describe("Benotungstool-Regeln (Client)", () => { }); it("nimmt 1, solange die Konfiguration keine Zahl liefert", () => { - // getCisConfig liefert den Schlüssel erst nach dem Laden; bis dahin gilt ein Antritt + // getCisConfig returns the key only after loading; until then, a default value applies expect(maxAntrittCount({}), "Schlüssel fehlt").to.eq(1); expect(maxAntrittCount({ CIS_GESAMTNOTE_MAX_ANTRITTE: null }), "Schlüssel ist null").to.eq(1); expect(maxAntrittCount(undefined), "keine Konfiguration").to.eq(1); expect(maxAntrittCount(null), "Konfiguration ist null").to.eq(1); }); - // Der Rückfall nutzt ??, nicht ||. Mit || würde eine konfigurierte 0 zu 1 werden und das - // Werkzeug liesse einen Antritt zu, den die Konfiguration verbietet. + // The fallback uses ??, not ||. If || were used, a configured 0 would become 1, and the + // tool would allow a start that the configuration prohibits. it("behält eine konfigurierte 0", () => { expect(maxAntrittCount(configWith(0))).to.eq(0); }); - // getMaxAntritte auf dem Server liefert ein int. Die Umwandlung hält den Wert auch dann eine - // Zahl, wenn er je als Text ankommt: die Aufrufer vergleichen ihn, die Tests streng. + // getMaxAntritte on the server returns an int. The conversion ensures that the value remains a + // number even if it is received as text: the callers compare it, and the tests are strict. it("gibt immer eine Zahl zurück", () => { expect(maxAntrittCount(configWith("4")), "Text mit Zahl").to.eq(4); expect(maxAntrittCount(configWith(3)), "bleibt eine Zahl").to.eq(3); @@ -94,10 +95,10 @@ describe("Benotungstool-Regeln (Client)", () => { expect(antrittCountStudent(student({ note: 1 }), ours, notenOptions)).to.eq(1); }); - // dieselbe Falle wie in brauchtNeueLvNote: die Note 0 ist eine Note, kein fehlender Wert + // Same pitfall as in “needsNewLvNote”: a score of 0 is a score, not a missing value it("zählt auch die Note 0 als ersten Antritt", () => { - const mitNullNote = [...notenOptions, { note: 0, lehre: true }]; - expect(antrittCountStudent(student({ note: 0 }), ours, mitNullNote)).to.eq(1); + const withZeroNote = [...notenOptions, { note: 0, lehre: true }]; + expect(antrittCountStudent(student({ note: 0 }), ours, withZeroNote)).to.eq(1); }); }); }); @@ -109,7 +110,7 @@ describe("Benotungstool-Regeln (Client)", () => { }); it("sperrt eine angerechnete Zeile trotz freier Antritte", () => { - // the server sets canAdd:false for an Anrechnung; the client must not second-guess it + // the server sets canAdd: false for an Anrechnung, the client must not second-guess it const s = withVerlauf({ canAdd: false, antrittCount: 0, angerechnet: true }); expect(canAddPruefung(s, ours)).to.be.false; }); @@ -124,8 +125,8 @@ describe("Benotungstool-Regeln (Client)", () => { ).to.be.false; }); - // Bekannte Lücke, hier festgenagelt: die LV-Note zählt nur solange gar keine Prüfung - // existiert. Deshalb schreibt die Freigabe Antritt 1 als echte Zeile. + // Known issue, addressed here: the course grade only counts if there is no exam + // at all. That is why the approval writes “Attempt 1” as a separate row. it("zählt die LV-Note nicht mehr mit, sobald eine Prüfung existiert", () => { const s = student({ note: 1, pruefungen: [{ note: 5 }] }); expect(antrittCountStudent(s, ours, notenOptions), "the LV note is no longer added").to.eq(1); @@ -133,12 +134,21 @@ describe("Benotungstool-Regeln (Client)", () => { }); }); + describe("isBestanden", () => { + it("folgt dem Verlauf und bleibt ohne Verlauf false", () => { + expect(isBestanden(withVerlauf({ canAdd: false, bestanden: true }))).to.be.true; + expect(isBestanden(withVerlauf({ canAdd: false, bestanden: false }))).to.be.false; + // the client cannot tell a pass without the server rules + expect(isBestanden(student({ note: 1 }))).to.be.false; + }); + }); + describe("brauchtNeueLvNote", () => { /** - * Der Verlauf schlägt lv_note, und zwar genau deshalb: lv_note trägt nur FREIGEGEBENE Noten - * (Benotungstool.js setzt es aus getLvGesamtNoten), hatLvNote dagegen zählt auch die - * eingetragene, noch nicht freigegebene Note mit. Noten.php nennt das Feld - * "ungefiltert, also inklusive noch nicht freigegebener". + * The history uses `lv_note`: `lv_note` only contains APPROVED grades + * (`Benotungstool.js` calculates it from `getLvGesamtNoten`), whereas `hatLvNote` also counts the + * entered grades that have not yet been approved. Noten.php refers to this field as + * “unfiltered, i.e., including those not yet approved.” */ it("meldet keinen Bedarf, wenn die LV-Note nur noch nicht freigegeben ist", () => { const s = { verlauf: { hatLvNote: true }, lv_note: null }; @@ -146,13 +156,13 @@ describe("Benotungstool-Regeln (Client)", () => { }); it("meldet Bedarf, wenn der Verlauf keine LV-Note kennt", () => { - // auch wenn lv_note gesetzt ist: der Verlauf entscheidet + // even if lv_note is set, the history takes precedence expect(brauchtNeueLvNote({ verlauf: { hatLvNote: false }, lv_note: 1 })).to.be.true; expect(brauchtNeueLvNote({ verlauf: { hatLvNote: false }, lv_note: null })).to.be.true; }); - // verlaufSummary hat für hatLvNote den Vorgabewert null. Ein Aufrufer, der ihn nicht - // übergibt, erzeugt damit den Hinweis "noch keine LV-Note". + // verlaufSummary has a default value of zero for hatLvNote. A caller who does not + // pass it will thus trigger the message “No LV note yet.” it("meldet Bedarf, wenn der Verlauf das Feld nicht trägt", () => { expect(brauchtNeueLvNote({ verlauf: {} })).to.be.true; }); @@ -164,8 +174,8 @@ describe("Benotungstool-Regeln (Client)", () => { expect(brauchtNeueLvNote({}), "Feld fehlt").to.be.true; }); - // Die Note 0 ("Teilnote") ist eine Note. Ein truthy-Test hielte sie für "keine Note" und - // das Werkzeug verspräche eine neue LV-Note, obwohl eine existiert. + // A grade of 0 (“partial grade”) is a grade. A truthy test would treat it as “no grade,” and + // the tool would promise a new course grade even though one already exists. it("erkennt die Note 0 als Note", () => { expect(brauchtNeueLvNote({ lv_note: 0 })).to.be.false; }); diff --git a/tests/cypress/local/.env.example b/tests/cypress/local/.env.example new file mode 100644 index 000000000..bc2c19dd3 --- /dev/null +++ b/tests/cypress/local/.env.example @@ -0,0 +1,25 @@ +# Local workstation: SSH tunnel to the database and configuration profiles via SFTP. +# Copy to tests/cypress/local/.env. The suite itself never reads this file. + +# ---- SSH tunnel (withTunnel.js) --------------------- --------------------------------------------- +# pg_hba.conf only allows the application server. NOTEN_DB_HOST in suites/.env remains the ACTUAL database. +# - The SSH host is usually NOT the web hostname +# - A key outside the OpenSSH default names is never automatically tried; specify it here, +# or leave NOTEN_SSH_KEY empty for the SSH agent +NOTEN_SSH_HOST= +NOTEN_SSH_PORT=22 +NOTEN_SSH_USER= +NOTEN_SSH_KEY= +NOTEN_SSH_PASSPHRASE= + +# ---- Configuration profiles (notenProfiles.js) ------------------------------------------------ --- +# Absolute path of the instance on its host. +NOTEN_REMOTE_ROOT= +# Only if the instance is on a different host than the tunnel. With NOTEN_PROFILE_SSH_HOST, every +# SSH value in the profiles comes from NOTEN_PROFILE_SSH_*; an empty key uses the SSH agent. +NOTEN_PROFILE_SSH_HOST= +NOTEN_PROFILE_SSH_USER= +NOTEN_PROFILE_SSH_KEY= + +# Only for an instance with a self-signed certificate: otherwise, the Node side will reject it. +# NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/tests/cypress/local/eslint.config.js b/tests/cypress/local/eslint.config.js new file mode 100644 index 000000000..a62b707c3 --- /dev/null +++ b/tests/cypress/local/eslint.config.js @@ -0,0 +1,87 @@ +const js = require("@eslint/js"); +const globals = require("globals"); +const pluginCypress = require("eslint-plugin-cypress"); +const prettier = require("eslint-config-prettier"); + +/** + * Nur die Cypress-Suite. Der übrige Code des Repos ist älter als dieses Setup; ihn mitzulinten + * würde hunderte Funde erzeugen, die niemand in dieser Story abarbeitet. + * + * Die no-restricted-syntax-Regeln setzen die Konventionen aus .claude/rules/benotungstool-tests.md durch. + * Sie sind der Grund für diese Datei: eine Konvention, die kein Werkzeug prüft, zerfällt. + * + * Pfade gelten ab dem Repo-Root: `npm run lint` in local/ wechselt dorthin. + */ +module.exports = [ + { ignores: ["tests/cypress/e2e/fhcomplete/**", "tests/cypress/e2e/*.cy.js"] }, + + // --- Node-Seite: Tasks, Werkzeuge, Profile, eigener Arbeitsplatz, Konfiguration (CommonJS) --- + { + files: ["tests/cypress/{tasks,tools,profiles,suites,local}/**/*.js", "cypress.config.js"], + languageOptions: { + ecmaVersion: 2023, + sourceType: "commonjs", + globals: { ...globals.node }, + }, + rules: { + ...js.configs.recommended.rules, + "no-unused-vars": ["warn", { argsIgnorePattern: "^_", caughtErrors: "none" }], + }, + }, + + // --- Browser-Seite: Specs und Support (ES-Module, im Cypress-Runner) --- + { + files: ["tests/cypress/e2e/**/*.js", "tests/cypress/support/**/*.js"], + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + globals: { ...globals.browser, ...globals.mocha, cy: "readonly", Cypress: "readonly", expect: "readonly" }, + }, + plugins: { cypress: pluginCypress }, + rules: { + ...js.configs.recommended.rules, + ...pluginCypress.configs.recommended.rules, + "no-unused-vars": ["warn", { argsIgnorePattern: "^_", caughtErrors: "none" }], + + /* + * Warnung statt Fehler. Das Muster .clear().type() steckt an vier Stellen im Page + * Object und läuft dort. Drei davon gehören zum Punktemodus, den nur das Profil + * "punkte" ausführt - eine Umstellung wäre hier nicht prüfbar. Für neuen Code + * bleibt der Hinweis sichtbar. + */ + "cypress/unsafe-to-chain-command": "warn", + }, + }, + + // --- die Konventionen der Suite --- + { + files: ["tests/cypress/e2e/specs/**/*.cy.js"], + rules: { + "no-restricted-syntax": [ + "error", + { + // skipIf / require* aus notenConfig.js sind der einzige Weg. + selector: "CallExpression[callee.property.name='skip'][callee.object.type='ThisExpression']", + message: + "Kein this.skip() im Spec. Nimm skipIf(this, condition, reason) oder einen require*-Helfer aus support/helpers/notenConfig.js.", + }, + { + // Die Noten-API zu stubben hebt genau die Serverregeln auf, die die Suite prüft. + selector: + "CallExpression[callee.object.name='cy'][callee.property.name='intercept'] > ObjectExpression:has(Property[key.name='body'])", + message: + "Kein Stub der Noten-API. cy.intercept nur zum Aliasen und Warten, siehe .claude/rules/benotungstool-tests.md.", + }, + ], + }, + }, + + // notenConfig.js hält die einzige Umsetzung von skip. + { + files: ["tests/cypress/support/helpers/notenConfig.js"], + rules: { "no-restricted-syntax": "off" }, + }, + + // muss zuletzt stehen: schaltet alle Stilregeln ab, die Prettier ohnehin setzt + prettier, +]; diff --git a/tests/cypress/local/notenProfiles.js b/tests/cypress/local/notenProfiles.js new file mode 100644 index 000000000..048635bec --- /dev/null +++ b/tests/cypress/local/notenProfiles.js @@ -0,0 +1,163 @@ +#!/usr/bin/env node +/** + * Configuration profiles for the grading tool suite, via SFTP on a c3p0 instance. + * + * node tests/cypress/local/notenProfiles.js status Profiles and the active profile + * node tests/cypress/local/notenProfiles.js apply For example, before running `cypress open` + * node tests/cypress/local/notenProfiles.js restore restore the originals + * node tests/cypress/local/notenProfiles.js run [api|ui|all] [a,b] one run per profile, followed by the report + * + * The profiles are located in tests/cypress/profiles/noten.js. The `run` command opens the SSH tunnel itself. + */ + +const path = require("path"); + +["local", "suites", "."].forEach((dir) => require("dotenv").config({ path: path.join(__dirname, "..", dir, ".env") })); + +const profileSwitch = require("./profileSwitch"); +const { openDbTunnel } = require("./withTunnel"); +const { profiles } = require("../profiles/noten"); + +const SCOPES = { + api: { spec: "tests/cypress/e2e/specs/{unit,api}/**/*.cy.js" }, + // Electron crashes on the grading tool page + ui: { spec: "tests/cypress/e2e/specs/ui/**/*.cy.js", browser: "chrome" }, + all: { spec: "tests/cypress/e2e/specs/**/*.cy.js", browser: "chrome" }, +}; + +const NOT_RUN = ["pending", "skipped"]; + +const testTitle = (run, test) => `${path.basename(run.spec.relative)} › ${test.title.join(" › ")}`; + +const column = (value, width) => String(value).padStart(width); + +/** Numbers per profile, the red tests per profile, and the tests that aren't running in any profile. */ +const report = (names, results) => { + const tests = new Map(); + const completed = []; + let failed = false; + + console.log(`\n${"=".repeat(78)}\n${"Profil".padEnd(20)} Tests grün rot pending\n${"-".repeat(78)}`); + + names.forEach((name) => { + const result = results[name]; + if (!result || !Array.isArray(result.runs)) { + failed = true; + console.log(`${name.padEnd(20)} Lauf gescheitert: ${result ? result.message : "kein Ergebnis"}`); + return; + } + + completed.push(name); + const counts = { passed: 0, failed: 0, pending: 0, skipped: 0 }; + result.runs.forEach((run) => + run.tests.forEach((test) => { + counts[test.state] = (counts[test.state] || 0) + 1; + const key = testTitle(run, test); + if (!tests.has(key)) tests.set(key, {}); + tests.get(key)[name] = test; + }), + ); + + if (counts.failed) failed = true; + const total = counts.passed + counts.failed + counts.pending + counts.skipped; + console.log( + `${name.padEnd(20)} ${column(total, 5)} ${column(counts.passed, 6)} ${column(counts.failed, 6)} ${column(counts.pending + counts.skipped, 7)}`, + ); + }); + + completed.forEach((name) => { + const failures = [...tests.entries()].filter( + ([, byProfile]) => byProfile[name] && byProfile[name].state === "failed", + ); + if (!failures.length) return; + + console.log(`\nRot in "${name}":`); + failures.forEach(([key, byProfile]) => + console.log(` - ${key}\n ${String(byProfile[name].displayError || "").split("\n")[0]}`), + ); + }); + + const neverRun = [...tests.keys()].filter((key) => + completed.every((name) => !tests.get(key)[name] || NOT_RUN.includes(tests.get(key)[name].state)), + ); + console.log(`\nIn keinem Profil gelaufen (${neverRun.length}):`); + neverRun.forEach((key) => console.log(` - ${key}`)); + console.log("=".repeat(78)); + + return failed ? 1 : 0; +}; + +const runProfiles = async (scope = "api", selection = null) => { + const options = SCOPES[scope]; + if (!options) { + console.error(`Unbekannter Umfang "${scope}". Erlaubt: ${Object.keys(SCOPES).join(", ")}`); + return 2; + } + + const names = selection + ? selection + .split(",") + .map((n) => n.trim()) + .filter(Boolean) + : Object.keys(profiles); + names.forEach((name) => profileSwitch.profileData(name)); + + const cypress = require("cypress"); + const results = {}; + const closeTunnel = await openDbTunnel(); + + try { + for (const name of names) { + console.log(`\n### Profil "${name}": ${profiles[name].description}\n`); + try { + await profileSwitch.apply(name); + results[name] = await cypress.run(options); + } catch (error) { + // ein gescheiterter Profilwechsel beendet nur diesen Lauf; der Bericht nennt den Grund + results[name] = { message: error.message }; + } + } + } finally { + await profileSwitch.restore(); + await closeTunnel(); + } + + return report(names, results); +}; + +const main = async () => { + const [command, first, second] = process.argv.slice(2); + + if (command === "run") return runProfiles(first, second); + + if (command === "apply" && first) { + await profileSwitch.apply(first); + return 0; + } + + if (command === "restore") { + if (!(await profileSwitch.restore())) console.log("Kein Profil war aktiv."); + return 0; + } + + if (command === "status") { + const state = await profileSwitch.status(); + console.log(state && state.profile ? `\nAktiv auf ${state.root}: "${state.profile}"` : "\nKein Profil aktiv."); + console.log("\nProfile:"); + Object.entries(profiles).forEach(([name, p]) => console.log(` ${name.padEnd(18)} ${p.description}`)); + console.log(""); + return 0; + } + + console.error( + "\n node tests/cypress/local/notenProfiles.js status | apply | restore | run [api|ui|all] [profile,profile]\n", + ); + return 2; +}; + +main() + .then((code) => process.exit(code)) + .catch((error) => { + console.error(`\n${error.message}\n`); + process.exit(1); + }); diff --git a/tests/cypress/local/package.json b/tests/cypress/local/package.json new file mode 100644 index 000000000..208b50d66 --- /dev/null +++ b/tests/cypress/local/package.json @@ -0,0 +1,25 @@ +{ + "private": true, + "description": "Eigener Arbeitsplatz: SSH-Tunnel, Profile, Lint und Format.", + "scripts": { + "lint": "cd ../../.. && eslint -c tests/cypress/local/eslint.config.js tests/cypress cypress.config.js", + "format": "cd ../../.. && prettier --config tests/cypress/local/package.json --write \"tests/cypress/**/*.js\" \"!tests/cypress/e2e/fhcomplete/**\" \"!tests/cypress/e2e/*.cy.js\"" + }, + "prettier": { + "useTabs": true, + "tabWidth": 4, + "printWidth": 120, + "endOfLine": "auto" + }, + "dependencies": { + "ssh2": "^1.17.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-cypress": "^7.0.2", + "globals": "^17.12.0", + "prettier": "^3.9.7" + } +} diff --git a/tests/cypress/local/profileSwitch.js b/tests/cypress/local/profileSwitch.js new file mode 100644 index 000000000..3e587a230 --- /dev/null +++ b/tests/cypress/local/profileSwitch.js @@ -0,0 +1,344 @@ +/** + * Configuration profiles for the grading tool suite on the test instance. + * + * A profile modifies application/config/noten.php and the define() switches in + * config/global.config.inc.php. The originals are located in the SSH user's home directory, i.e., + * outside the web root: global.config.inc.php contains login credentials. + * + * state.json stores the MD5 hash of every file that has been written. If a file differs from this hash, it has been + * redeployed since then. It is then considered the original, and the old backup is invalidated. + */ + +const crypto = require("crypto"); +const path = require("path"); +const { resolveAuth } = require("./sshTunnel"); +const { base, profiles } = require("../profiles/noten"); + +const PREFIX = process.env.TEST_ENV_PREFIX || "TEST"; + +// The instance may be located on a different host than the SSH tunnel to the database. If +// _PROFILE_SSH_HOST is set, all SSH values come from _PROFILE_SSH_*; without a key, +// the SSH agent is used. +const envValue = (key) => { + const ownHost = key.startsWith("SSH_") && Boolean(process.env[`${PREFIX}_PROFILE_SSH_HOST`]); + return process.env[`${PREFIX}_${ownHost ? "PROFILE_" : ""}${key}`]; +}; + +const FILES = { + config: "application/config/noten.php", + flags: "config/global.config.inc.php", +}; + +// relative to the home directory; that's where every SFTP session starts +const BACKUP_DIR = ".cypress-noten-profile"; +const STATE_FILE = `${BACKUP_DIR}/state.json`; + +const NAME_PATTERN = /^[a-z0-9-]+$/; +const KEY_PATTERN = /^[A-Z0-9_]+$/; + +const md5 = (content) => crypto.createHash("md5").update(content).digest("hex"); + +const sftpCall = (fn) => new Promise((resolve, reject) => fn((err, value) => (err ? reject(err) : resolve(value)))); + +const sshConfigured = () => Boolean(envValue("SSH_HOST") && envValue("SSH_USER")); + +// --- SFTP --- + +const connect = () => + new Promise((resolve, reject) => { + const { Client } = require("ssh2"); + + let auth; + try { + auth = resolveAuth({ + keyPath: envValue("SSH_KEY"), + passphrase: envValue("SSH_PASSPHRASE"), + agent: envValue("SSH_AGENT"), + }); + } catch (error) { + reject(error); + return; + } + + const client = new Client(); + client + .on("ready", () => client.sftp((err, sftp) => (err ? reject(err) : resolve({ client, sftp })))) + .on("error", (err) => + reject(new Error(`SSH zu ${envValue("SSH_USER")}@${envValue("SSH_HOST")} gescheitert: ${err.message}`)), + ) + .connect({ + host: envValue("SSH_HOST"), + port: Number(envValue("SSH_PORT") || 22), + username: envValue("SSH_USER"), + privateKey: auth.privateKey, + passphrase: auth.passphrase, + agent: auth.agent, + readyTimeout: 15000, + }); + }); + +const withSftp = async (fn) => { + const { client, sftp } = await connect(); + try { + return await fn(sftp); + } finally { + client.end(); + } +}; + +const exists = (sftp, file) => new Promise((resolve) => sftp.stat(file, (err) => resolve(!err))); +const read = (sftp, file) => sftpCall((cb) => sftp.readFile(file, cb)); +const writeRaw = (sftp, file, content, mode) => sftpCall((cb) => sftp.writeFile(file, content, { mode }, cb)); + +/** Writes to a temporary file to ensure that no request reads an incomplete configuration. */ +const write = async (sftp, file, content) => { + // .php: ein Webserver führt die Datei aus, statt ihre Zugangsdaten auszuliefern + const temp = `${file}.cypress-temp.php`; + await writeRaw(sftp, temp, content, 0o644); + await sftpCall((cb) => sftp.ext_openssh_rename(temp, file, cb)); +}; + +const backupOf = (remotePath) => `${BACKUP_DIR}/${path.posix.basename(remotePath)}`; + +const readState = async (sftp) => + (await exists(sftp, STATE_FILE)) ? JSON.parse((await read(sftp, STATE_FILE)).toString("utf8")) : null; + +const writeState = (sftp, state) => writeRaw(sftp, STATE_FILE, JSON.stringify(state, null, 2), 0o600); + +// --- Modifying the files --- + +const phpString = (text) => `'${String(text).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; + +const phpValue = (value) => { + if (value === null) return "null"; + if (typeof value === "boolean" || typeof value === "number") return String(value); + if (typeof value === "string") return phpString(value); + throw new Error(`Ein define()-Schalter nimmt nur einen einfachen Wert, nicht ${JSON.stringify(value)}.`); +}; + +// ASCII only: this block does not change the file's encoding +const asciiJson = (value) => + JSON.stringify(value).replace(/[-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`); + +/** Appends the profile keys to noten.php. The later assignment takes precedence. */ +const withConfig = (original, name, config) => { + const text = original.toString("latin1"); + const block = + `\n// cypress-profil "${name}": nur auf der Testinstanz, die Suite stellt das Original wieder her\n` + + `$config = array_replace($config, json_decode(${phpString(asciiJson(config))}, true));\n`; + const end = text.search(/\?>\s*$/); + + return Buffer.from(end === -1 ? text + block : text.slice(0, end) + block + text.slice(end), "latin1"); +}; + +/** Replaces the `define()` line for each switch. A second `define()` outputs a notice to the page. */ +const withFlags = (original, flags) => { + let text = original.toString("latin1"); + + Object.entries(flags).forEach(([flag, value]) => { + const pattern = new RegExp(`^([ \\t]*)define\\(\\s*['"]${flag}['"]\\s*,[^;]*;`, "m"); + if (!pattern.test(text)) { + throw new Error( + `${FILES.flags} definiert ${flag} nicht. Ein Profil ändert nur einen bestehenden Schalter.`, + ); + } + text = text.replace(pattern, (match, indent) => `${indent}define('${flag}', ${phpValue(value)});`); + }); + + return Buffer.from(text, "latin1"); +}; + +// --- Profile --- + +/** Profile, including the base. expect lists values from getCisConfig that a flag changes only indirectly. */ +const profileData = (name) => { + if (!NAME_PATTERN.test(String(name)) || !Object.prototype.hasOwnProperty.call(profiles, name)) { + throw new Error(`Unbekanntes Profil "${name}". Vorhanden: ${Object.keys(profiles).join(", ")}`); + } + + const data = { + config: { ...base.config, ...profiles[name].config }, + flags: { ...base.flags, ...profiles[name].flags }, + expect: { ...profiles[name].expect }, + }; + + [...Object.keys(data.config), ...Object.keys(data.flags)].forEach((key) => { + if (!KEY_PATTERN.test(key)) throw new Error(`Ungültiger Schlüssel "${key}" in Profil "${name}".`); + }); + + return data; +}; + +/** Stellt eine Datei her, die ein Profil geschrieben hat. Eine neu ausgerollte Datei bleibt stehen. */ +const restoreFile = async (sftp, state, fileKey) => { + const entry = state.files[fileKey]; + if (!entry) return; + + const remotePath = `${state.root}/${FILES[fileKey]}`; + const backup = backupOf(remotePath); + + if (await exists(sftp, backup)) { + if (md5(await read(sftp, remotePath)) === entry.md5) { + await write(sftp, remotePath, await read(sftp, backup)); + } else { + console.warn(`[profile] ${remotePath} ist seit dem Profil neu ausgerollt und bleibt stehen.`); + } + await sftpCall((cb) => sftp.unlink(backup, cb)); + } + + delete state.files[fileKey]; + await writeState(sftp, state); +}; + +/** Creates a file generated by a profile. A newly rolled-out file remains in place. */ +const applyFile = async (sftp, state, fileKey, transform) => { + const remotePath = `${state.root}/${FILES[fileKey]}`; + const backup = backupOf(remotePath); + const current = await read(sftp, remotePath); + const entry = state.files[fileKey]; + const fromProfile = Boolean(entry) && md5(current) === entry.md5; + + let original = current; + if (fromProfile) { + if (!(await exists(sftp, backup))) { + throw new Error(`Die Sicherung ${backup} fehlt. Das Original von ${remotePath} ist unbekannt.`); + } + original = await read(sftp, backup); + } else { + if (entry) + console.warn(`[profile] ${remotePath} ist seit dem letzten Profil neu ausgerollt und gilt als Original.`); + await writeRaw(sftp, backup, current, 0o600); + } + + const updated = transform(original); + + // zuerst der Zustand: bricht das Schreiben ab, gilt die Datei beim nächsten Lauf als Original + state.files[fileKey] = { md5: md5(updated) }; + await writeState(sftp, state); + await write(sftp, remotePath, updated); +}; + +const matches = (actual, expected) => + typeof expected === "boolean" ? Boolean(actual) === expected : JSON.stringify(actual) === JSON.stringify(expected); + +/** The data returned by getCisConfig, or null with a reason in the event of a temporary error (network, HTTP 5xx). */ +const readCisConfig = async (url, auth) => { + let response; + try { + response = await fetch(url, { headers: { Authorization: auth } }); + } catch (error) { + return { actual: null, reason: error.message }; + } + + const text = await response.text(); + try { + const actual = JSON.parse(text).data; + if (actual) return { actual }; + } catch (e) { + // keine JSON-Antwort + } + + // unter 500 steht eine PHP-Meldung vor dem JSON: die Datei ist kaputt, Warten hilft nicht + if (response.status < 500) { + throw new Error( + `getCisConfig antwortet nach dem Profilwechsel mit HTTP ${response.status}: ${text.slice(0, 300)}`, + ); + } + return { actual: null, reason: `getCisConfig antwortet mit HTTP ${response.status}` }; +}; + +/** Waits until getCisConfig returns the profile. PHP-FPM does not read a modified file until opcache.revalidate_freq has elapsed. */ +const verifyInstance = async (data) => { + const expected = { ...data.config, ...data.flags, ...data.expect }; + const baseUrl = String(process.env.BASE_URL).replace(/\/+$/, ""); + const url = `${baseUrl}/index.ci.php/api/frontend/v1/Noten/getCisConfig`; + const auth = `Basic ${Buffer.from(`${process.env.NOTEN_USER}:${process.env.NOTEN_PASSWORD}`).toString("base64")}`; + const deadline = Date.now() + 60000; + + for (;;) { + const { actual, reason } = await readCisConfig(url, auth); + let pending = reason; + + if (actual) { + const checkable = Object.keys(expected).filter((key) => key in actual); + if (!checkable.length) { + console.warn("[profile] getCisConfig liefert keinen Schlüssel des Profils. Der Wechsel ist ungeprüft."); + return; + } + + const deviating = checkable.filter((key) => !matches(actual[key], expected[key])); + if (!deviating.length) return; + + const lines = deviating.map( + (k) => ` ${k}: ${JSON.stringify(actual[k])} statt ${JSON.stringify(expected[k])}`, + ); + pending = `die Instanz zeigt das Profil nicht:\n${lines.join("\n")}`; + } + + if (Date.now() > deadline) throw new Error(`Profilwechsel nach 60 Sekunden ungeprüft, ${pending}`); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } +}; + +// --- öffentlich --- + +/** Retrieves the active profile. Returns its name or null. */ +const restore = async () => { + if (!sshConfigured()) return null; + + return withSftp(async (sftp) => { + const state = await readState(sftp); + if (!state || !Object.keys(state.files).length) return null; + + const previous = state.profile; + for (const fileKey of Object.keys(FILES)) await restoreFile(sftp, state, fileKey); + + state.profile = null; + await writeState(sftp, state); + console.log(`[profile] "${previous}" ist zurückgenommen.`); + return previous; + }); +}; + +/** Sets the instance to a profile. If no name is specified, it uses the defaults. */ +const apply = async (name) => { + if (!name) return restore(); + + const data = profileData(name); + const root = envValue("REMOTE_ROOT"); + if (!sshConfigured() || !root) { + throw new Error( + `Profil "${name}" braucht ${PREFIX}_SSH_HOST, ${PREFIX}_SSH_USER und ${PREFIX}_REMOTE_ROOT in tests/cypress/local/.env.`, + ); + } + + await withSftp(async (sftp) => { + if (!(await exists(sftp, BACKUP_DIR))) await sftpCall((cb) => sftp.mkdir(BACKUP_DIR, { mode: 0o700 }, cb)); + + const state = (await readState(sftp)) || { files: {} }; + if (state.root && state.root !== root && Object.keys(state.files).length) { + throw new Error( + `Auf ${state.root} ist noch "${state.profile}" aktiv. Zuerst: node tests/cypress/local/notenProfiles.js restore`, + ); + } + state.root = root; + state.profile = name; + + if (Object.keys(data.config).length) + await applyFile(sftp, state, "config", (o) => withConfig(o, name, data.config)); + else await restoreFile(sftp, state, "config"); + + if (Object.keys(data.flags).length) await applyFile(sftp, state, "flags", (o) => withFlags(o, data.flags)); + else await restoreFile(sftp, state, "flags"); + + await writeState(sftp, state); + }); + + await verifyInstance(data); + console.log(`[profile] "${name}" ist aktiv.`); + return name; +}; + +const status = () => (sshConfigured() ? withSftp(readState) : Promise.resolve(null)); + +module.exports = { apply, restore, status, profileData }; diff --git a/tests/cypress/local/readme_local.txt b/tests/cypress/local/readme_local.txt new file mode 100644 index 000000000..caa4d73a3 --- /dev/null +++ b/tests/cypress/local/readme_local.txt @@ -0,0 +1,137 @@ +LOCAL WORKSTATION - SSH TUNNEL AND CONFIGURATION PROFILES +========================================================= +These tools are for local workstations only. No file of the suite refers to this folder. The team does +not need it to run the tests. + +The tools do two things: + +- They open an SSH tunnel to the test database. pg_hba.conf accepts only the application server, so + a workstation cannot connect directly. +- They switch the test instance to a configuration profile, run the suite, and put the original + configuration back. + +1. Files +----------------- + +withTunnel.js Opens the tunnel, starts a command, and closes the tunnel when the command ends. +sshTunnel.js The tunnel itself (package ssh2). withTunnel.js and profileSwitch.js use it. +notenProfiles.js Command line for the profiles: status, apply, restore, run. +profileSwitch.js Writes the profile over SFTP, keeps the backups, checks the instance. +eslint.config.js Lint rules of the suite. They enforce the conventions in .claude/rules/. +package.json ssh2, ESLint and Prettier. Cypress, dotenv and pg come from the repository root. +.env SSH access and the path of the instance (gitignored). + +The profiles are in tests/cypress/profiles/noten.js. That file is part of the suite. + + +2. Preparation +--------------- + +Install the packages in this folder: + + cd tests/cypress/local + npm install + +Create the credentials file: + +tests/cypress/local/.env.example -> tests/cypress/local/.env + +The tools also read tests/cypress/.env (BASE_URL) and tests/cypress/suites/.env (NOTEN_USER, +NOTEN_DB_*). Fill out these two files first. readme_benotungstool.txt explains them. + +- NOTEN_DB_HOST in suites/.env stays the real database host, as the SSH host sees it. Do not write + 127.0.0.1. The tunnel changes the value only for the command that it starts. +- NOTEN_SSH_HOST is the SSH host. It is usually not the web hostname. +- Without NOTEN_SSH_KEY, the tools use only the SSH agent. They do not read ~/.ssh themselves. +- Use the agent if possible: run ssh-add and keep NOTEN_SSH_KEY empty. On Windows, the service + "OpenSSH Authentication Agent" must run. +- NOTEN_SSH_PASSPHRASE keeps the passphrase in plain text. Use it only if the agent is not possible. +- NOTEN_REMOTE_ROOT is the absolute path of the instance on its host. Only the profiles need it. +- Set NOTEN_PROFILE_SSH_* only when the instance runs on a different host than the database tunnel. +- The tools need Node 18 or later. + + +3. SSH tunnel +-------------- + +Run the commands from the repository root. Put withTunnel.js in front of each command that needs +the database: + + node tests/cypress/local/withTunnel.js npm run noten:check + node tests/cypress/local/withTunnel.js npm run noten:api + node tests/cypress/local/withTunnel.js npm run noten:ui + node tests/cypress/local/withTunnel.js npx cypress open + +withTunnel.js opens the tunnel on a free local port. Then it sets NOTEN_DB_HOST=127.0.0.1 and +NOTEN_DB_PORT= for the command. The .env files do not change. When the command ends, the tunnel +closes. Ctrl+C stops the command first and then the tunnel. + +The same result without this code: open the tunnel in a second terminal, and write fixed values in +suites/.env. + + ssh -N -L 15432::5432 @ + + NOTEN_DB_HOST=127.0.0.1 + NOTEN_DB_PORT=15432 + + +4. Configuration profiles +-------------------------- + +The suite tests the configuration it finds. Some rules have a second branch, for example two +Antritte instead of three. A profile switches the instance to such a branch. + + node tests/cypress/local/notenProfiles.js status Active profile and all profiles. + node tests/cypress/local/notenProfiles.js apply Switch the instance to a profile. + node tests/cypress/local/notenProfiles.js restore Put the original files back. + node tests/cypress/local/notenProfiles.js run [api|ui|all] [a,b] One run for each profile. + + +5. Lint and format +------------------- + +Run the commands in this folder. The paths in eslint.config.js start at the repository root, so +both scripts change to the root first. + + npm run lint ESLint for tests/cypress and cypress.config.js. Add "-- --fix" to fix. + npm run format Prettier for tests/cypress: tabs, 120 columns. It writes the files. + + +6. Debugging +------------------------ + +"SSH to @ ... failed" or "SSH zu @ gescheitert" + The SSH host is often not the web hostname. Without NOTEN_SSH_KEY. + Run ssh-add, or write the key path in NOTEN_SSH_KEY. + +"SSH key does not exist" + The path in NOTEN_SSH_KEY is wrong. A path can start with ~. + +The tunnel is up, but the database connection closes immediately + The SSH host cannot reach NOTEN_DB_HOST. If Postgres runs on the SSH host itself, write localhost. + +"Profilwechsel nach 60 Sekunden ungeprüft" + The instance at BASE_URL does not show the profile. The message lists each key with the actual and + the expected value. Make sure that NOTEN_REMOTE_ROOT is the path of the instance at BASE_URL. + +"getCisConfig antwortet nach dem Profilwechsel mit HTTP ..." + HTTP 401: NOTEN_USER or NOTEN_PASSWORD is wrong. Other codes: the changed file contains a PHP + error. Run restore, then examine the values of the profile. + +"Auf ist noch "" aktiv" + A profile is still active on a different NOTEN_REMOTE_ROOT. Run restore. It uses the path in + state.json. + +"Die Sicherung ... fehlt" + The backup in ~/.cypress-noten-profile/ is missing, so the original file is unknown. Deploy the + file again. The tool then takes it as the original. + +"... definiert nicht" + The profile sets a flag that config/global.config.inc.php does not define. Add the define() line + on the instance, or remove the flag from the profile. + +Ctrl+C during notenProfiles.js run + The instance stays on the current profile. Run restore. + +Self-signed certificate on the instance + Set NODE_TLS_REJECT_UNAUTHORIZED=0 in local/.env. \ No newline at end of file diff --git a/tests/cypress/tasks/sshTunnel.js b/tests/cypress/local/sshTunnel.js similarity index 95% rename from tests/cypress/tasks/sshTunnel.js rename to tests/cypress/local/sshTunnel.js index a7ad23e06..92af2b8ea 100644 --- a/tests/cypress/tasks/sshTunnel.js +++ b/tests/cypress/local/sshTunnel.js @@ -11,9 +11,6 @@ let server = null; let sshClient = null; let localPort = null; -/** Local end of the forward, or null. Lets the pool bind without going through the environment. */ -const tunnelPort = () => localPort; - const resolveAgent = (agent) => { if (agent && agent !== "true") return agent; if (process.env.SSH_AUTH_SOCK) return process.env.SSH_AUTH_SOCK; @@ -139,4 +136,4 @@ const ensureTunnel = async (cfg) => { } }; -module.exports = { ensureTunnel, closeTunnel, tunnelPort, resolveAuth }; +module.exports = { ensureTunnel, closeTunnel, resolveAuth }; diff --git a/tests/cypress/local/withTunnel.js b/tests/cypress/local/withTunnel.js new file mode 100644 index 000000000..640a07e4d --- /dev/null +++ b/tests/cypress/local/withTunnel.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * Runs a command using the database behind an SSH tunnel. For your own workstation only: + * No part of the suite references this folder. + * + * node tests/cypress/local/withTunnel.js npm run noten:api + * node tests/cypress/local/withTunnel.js npm run noten:check + * node tests/cypress/local/withTunnel.js npx cypress open + * + * The tunnel sets the command’s _DB_HOST and _DB_PORT to its local endpoint. + */ + +const path = require("path"); +const { spawn } = require("child_process"); +const { ensureTunnel, closeTunnel } = require("./sshTunnel"); + +["local", "suites"].forEach((dir) => require("dotenv").config({ path: path.join(__dirname, "..", dir, ".env") })); + +const PREFIX = process.env.TEST_ENV_PREFIX || "TEST"; +const env = (key) => process.env[`${PREFIX}_${key}`]; + +/** Opens the tunnel and redirects the DB values in process.env to it. Provides the function to close it. */ +const openDbTunnel = async () => { + const tunnel = await ensureTunnel({ + sshHost: env("SSH_HOST"), + sshPort: Number(env("SSH_PORT") || 22), + sshUser: env("SSH_USER"), + keyPath: env("SSH_KEY"), + passphrase: env("SSH_PASSPHRASE"), + agent: env("SSH_AGENT"), + dbHost: env("DB_HOST"), + dbPort: Number(env("DB_PORT") || 5432), + }); + if (!tunnel.tunnelled) throw new Error(`SSH tunnel failed: ${tunnel.reason}`); + + process.env[`${PREFIX}_DB_HOST`] = "127.0.0.1"; + process.env[`${PREFIX}_DB_PORT`] = String(tunnel.localPort); + return closeTunnel; +}; + +// cmd.exe runs npm and npx only as command-line tools +const commandLine = (parts) => parts.map((p) => (/[\s,;=&|<>^]/.test(p) ? `"${p}"` : p)).join(" "); + +const main = async () => { + const parts = process.argv.slice(2); + if (!parts.length) { + console.error("\n node tests/cypress/local/withTunnel.js [arguments]\n"); + return 2; + } + + const close = await openDbTunnel(); + + // Strg+C erreicht auch den Befehl; der Tunnel bleibt offen, bis der Befehl endet + process.on("SIGINT", () => {}); + + return new Promise((resolve) => { + const child = spawn(commandLine(parts), { stdio: "inherit", shell: true }); + const finish = async (code) => { + await close(); + resolve(code); + }; + child.on("error", (error) => { + console.error(`\n${error.message}\n`); + finish(1); + }); + child.on("exit", (code) => finish(code === null ? 1 : code)); + }); +}; + +if (require.main === module) { + main() + .then((code) => process.exit(code)) + .catch((error) => { + console.error(`\n${error.message}\n`); + process.exit(1); + }); +} + +module.exports = { openDbTunnel }; diff --git a/tests/cypress/profiles/noten.js b/tests/cypress/profiles/noten.js index 29b7d45aa..22601c45f 100644 --- a/tests/cypress/profiles/noten.js +++ b/tests/cypress/profiles/noten.js @@ -1,34 +1,33 @@ /** - * Konfigurationsprofile der Benotungstool-Suite. + * Configuration profiles for the Grading Tool Suite. * - * Ein Profil nennt nur die Abweichung vom Stand der Instanz. config ändert application/config/noten.php, - * flags ändert die define()-Schalter in config/global.config.inc.php. Nach dem Wechsel prüft die Suite - * jeden Schlüssel, den getCisConfig unter demselben Namen liefert. pruefe nennt Werte, die ein Flag nur - * mittelbar ändert oder die getCisConfig aufgelöst liefert. + * A profile specifies only the deviation from the instance's current state. “config” modifies application/config/noten.php, + * “flags” modifies the define() flags in config/global.config.inc.php. After the change, the suite checks + * every key that getCisConfig returns under the same name. expect specifies values that a flag only + * indirectly changes or that getCisConfig returns after resolution. * - * Ein Profil schaltet Zweige ein, die kein anderes Profil prüft. `npm run cy:noten:profile` listet am - * Ende die Tests, die in keinem Profil laufen. + * A profile enables branches that no other profile checks. A run over all profiles lists, at the + * end, the tests that do not run in any profile. */ module.exports = { - // gilt in jedem Profil. Die Freigabemail bleibt an: die Entwicklungsinstanzen stellen jede Mail in ein - // Debug-Postfach zu. - basis: {}, + // applies to every profile + base: {}, - profile: { - standard: { - beschreibung: "die Konfiguration der Instanz", + profiles: { + default: { + description: "die Konfiguration der Instanz", }, - auslieferung: { - beschreibung: "die Schalter aus global.config-default.inc.php: TERMIN3 an", + shipped: { + description: "die Schalter aus global.config-default.inc.php: TERMIN3 an", flags: { CIS_GESAMTNOTE_PRUEFUNG_TERMIN3: true }, // 1 + TERMIN2 + TERMIN3 + KOMMPRUEF, solange CIS_GESAMTNOTE_MAX_ANTRITTE fehlt - pruefe: { CIS_GESAMTNOTE_MAX_ANTRITTE: 4 }, + expect: { CIS_GESAMTNOTE_MAX_ANTRITTE: 4 }, }, - "ohne-erstantritt": { - beschreibung: "kein Pfad legt Antritt 1 an, die Freigabe verlangt kein Passwort", + "no-erstantritt": { + description: "kein Pfad legt Antritt 1 an, die Freigabe verlangt kein Passwort", config: { CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME: false, CIS_GESAMTNOTE_FREIGABE_PASSWORT: false, @@ -36,12 +35,12 @@ module.exports = { }, final: { - beschreibung: "eine freigegebene Note ist endgültig", + description: "eine freigegebene Note ist endgültig", config: { CIS_GESAMTNOTE_FREIGABE_FINAL: true }, }, - "zwei-antritte": { - beschreibung: "zwei Antritte, die kommissionelle Prüfung trägt die Studierendenverwaltung ein", + "two-antritte": { + description: "zwei Antritte, die kommissionelle Prüfung trägt die Studierendenverwaltung ein", config: { CIS_GESAMTNOTE_MAX_ANTRITTE: 2, CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF: false, @@ -49,19 +48,19 @@ module.exports = { }, "kommpruef-stv": { - beschreibung: "drei Antritte, die kommissionelle Prüfung trägt die Studierendenverwaltung ein", + description: "drei Antritte, die kommissionelle Prüfung trägt die Studierendenverwaltung ein", config: { CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF: false }, }, - "ohne-kommission": { - beschreibung: "kein Antritt ist kommissionell", + "no-kommission": { + description: "kein Antritt ist kommissionell", config: { CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT: 0 }, // getCisConfig löst 0 zu null auf - pruefe: { CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT: null }, + expect: { CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT: null }, }, rollen: { - beschreibung: "die Assistenz darf weder freigeben noch die kommissionelle Prüfung anlegen", + description: "die Assistenz darf weder freigeben noch die kommissionelle Prüfung anlegen", config: { CIS_GESAMTNOTE_ROLLENMATRIX: { "lehre/benotungstool": ["vorschlag", "pruefung", "kommpruef", "freigabe", "import"], @@ -70,43 +69,43 @@ module.exports = { }, }, - // Die Specs legen Antritte im Abstand von 30 Tagen an, mit einem entschuldigten Termin dazwischen - // 60 Tage. Beide Grenzen lassen diese Abstände zu. - abstand: { - beschreibung: "Wartefrist 14 Tage und Höchstfrist 400 Tage zwischen zwei Antritten", + // The specifications call for appearances at 30-day intervals, with one excused appointment in between + // 60 days. Both limits allow for these intervals. + "antritt-gap": { + description: "Wartefrist 14 Tage und Höchstfrist 400 Tage zwischen zwei Antritten", config: { CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE: 14, CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE: 400, }, }, - verbesserung: { - beschreibung: "Antritt nach einer positiven Note, die bessere Note bleibt LV-Note", + notenverbesserung: { + description: "Antritt nach einer positiven Note, die bessere Note bleibt LV-Note", config: { CIS_GESAMTNOTE_NOTENVERBESSERUNG: true, CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT: true, }, }, - // nicht mit abstand: am selben Tag beträgt der Abstand 0 Tage - "gleicher-tag": { - beschreibung: "ein neuer Termin darf am Tag eines bestehenden liegen", + // not including the start gap: on the same day, the gap is 0 days + "same-day": { + description: "ein neuer Termin darf am Tag eines bestehenden liegen", config: { CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG: true }, }, - "frist-ausnahme": { - beschreibung: "die Lehrenden dürfen nach der Eingabefrist eintragen", + "frist-exception": { + description: "die Lehrenden dürfen nach der Eingabefrist eintragen", config: { CIS_GESAMTNOTE_FRIST_AUSNAHME: ["lehre/benotungstool"] }, - pruefe: { CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT: true }, + expect: { CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT: true }, }, - "frist-nur-datum": { - beschreibung: "nur das Prüfungsdatum ist an die Frist gebunden, die Eingabe nicht", + "frist-date-only": { + description: "nur das Prüfungsdatum ist an die Frist gebunden, die Eingabe nicht", config: { CIS_GESAMTNOTE_FRIST_EINGABE: false }, }, - offen: { - beschreibung: + open: { + description: "frühere Noten änderbar, künftiges Benotungsdatum, keine Datumsfrist, ein Termin hebt die Freigabe nicht auf", config: { CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN: false, @@ -117,7 +116,7 @@ module.exports = { }, notenimport: { - beschreibung: "der Notenimport ohne Datum, mit dem Kürzel aus tbl_note.anmerkung", + description: "der Notenimport ohne Datum, mit dem Kürzel aus tbl_note.anmerkung", config: { CIS_GESAMTNOTE_NOTENIMPORT: true, CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL: true, @@ -125,13 +124,13 @@ module.exports = { }, punkte: { - beschreibung: "Punktemodus", + description: "Punktemodus", flags: { CIS_GESAMTNOTE_PUNKTE: true }, }, - // BESSERE_GEWINNT bleibt aus: eine schlechtere Verbesserung schreibt die letzte Note - "punkte-verbesserung": { - beschreibung: "Punktemodus mit Notenverbesserung und Notenimport", + // “BESSERE_GEWINNT” is omitted: a lower improvement results in the final grade + "punkte-notenverbesserung": { + description: "Punktemodus mit Notenverbesserung und Notenimport", flags: { CIS_GESAMTNOTE_PUNKTE: true }, config: { CIS_GESAMTNOTE_NOTENVERBESSERUNG: true, diff --git a/tests/cypress/suites/noten.js b/tests/cypress/suites/noten.js index 85eec77b8..fe0c95a13 100644 --- a/tests/cypress/suites/noten.js +++ b/tests/cypress/suites/noten.js @@ -2,23 +2,27 @@ * Wires the Gesamtnoteneingabe suite into cypress.config.js: its cy.task handlers, its run hooks and * the Cypress.env() values its specs read. * - * The split is the point -- nothing NOTEN_* belongs in the config, and nothing generic belongs - * here. A second suite adds its own file next to this one and the config changes by one require. + * The suite's keys come from tests/cypress/suites/.env or from the environment. */ +const path = require("path"); + +// before the task files: db.js reads TEST_ENV_PREFIX when it loads +require("dotenv").config({ path: path.join(__dirname, ".env") }); + const { registerNotenDbTasks } = require("../tasks/notenDb"); -const profil = require("../tasks/notenProfil"); +const { closeDb } = require("../tasks/db"); // cy.request sends one request after the other; the lock test needs two at the same time. // Basic auth without a session cookie: PHP serializes two requests of one session on the session lock, // and the test would no longer reach the advisory lock. -const parallelPost = ({ path, bodies }) => { +const parallelPost = ({ path: apiPath, bodies }) => { const base = String(process.env.BASE_URL).replace(/\/+$/, ""); - const auth = "Basic " + Buffer.from(`${process.env.USER_NAME}:${process.env.USER_PASSWORD}`).toString("base64"); + const auth = "Basic " + Buffer.from(`${process.env.NOTEN_USER}:${process.env.NOTEN_PASSWORD}`).toString("base64"); return Promise.all( bodies.map((body) => - fetch(`${base}/index.ci.php/api/frontend/v1/Noten/${path}`, { + fetch(`${base}/index.ci.php/api/frontend/v1/Noten/${apiPath}`, { method: "POST", headers: { Authorization: auth, "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -34,35 +38,38 @@ const parallelPost = ({ path, bodies }) => { ); }; +const env = () => ({ + // the lecturer the suite works as; separate from USER_NAME, which belongs to every suite + NOTEN_USER: process.env.NOTEN_USER || null, + NOTEN_PASSWORD: process.env.NOTEN_PASSWORD || null, + + // Optional pins; semester and LV are discovered at runtime when unset. + NOTEN_SEM: process.env.NOTEN_SEM || null, + NOTEN_LV_ID: process.env.NOTEN_LV_ID || null, + + // LDAP password for the Freigabe specs; empty = NOTEN_PASSWORD + NOTEN_FREIGABE_PASSWORD: process.env.NOTEN_FREIGABE_PASSWORD || null, + + // Access-control specs need real per-role credentials: this API has no impersonation. + NOTEN_TEACHER_USER: process.env.NOTEN_TEACHER_USER || null, + NOTEN_TEACHER_PASSWORD: process.env.NOTEN_TEACHER_PASSWORD || null, + NOTEN_FOREIGN_LV_ID: process.env.NOTEN_FOREIGN_LV_ID || null, + + // assistant account for the role matrix: carries only lehre/benotungstool_assistenz + NOTEN_ASSISTENZ_USER: process.env.NOTEN_ASSISTENZ_USER || null, + NOTEN_ASSISTENZ_PASSWORD: process.env.NOTEN_ASSISTENZ_PASSWORD || null, +}); + module.exports = { - registerTasks: (on) => { + setupNodeEvents: (on, config) => { registerNotenDbTasks(on); on("task", { "noten:http:parallel": parallelPost }); - }, - // NOTEN_PROFIL switches the instance before the run. Without it the run restores the originals, so an - // aborted run does not leave the instance on a profile. - beforeRun: (config) => profil.anwenden(config.env.NOTEN_PROFIL || null), - afterRun: (config) => (config.env.NOTEN_PROFIL ? profil.wiederherstellen() : null), + // the pool closes after the run, so the database keeps no idle connections of it + on("after:run", () => closeDb()); - env: { - // Optional pins; semester and LV are discovered at runtime when unset. - NOTEN_SEM: process.env.NOTEN_SEM || null, - NOTEN_LV_ID: process.env.NOTEN_LV_ID || null, - - // configuration profile from tests/cypress/profiles/noten.js; empty = the instance as deployed - NOTEN_PROFIL: process.env.NOTEN_PROFIL || null, - - // LDAP password for the Freigabe specs; empty = USER_PASSWORD - NOTEN_FREIGABE_PASSWORD: process.env.NOTEN_FREIGABE_PASSWORD || null, - - // Access-control specs need real per-role credentials: this API has no impersonation. - NOTEN_TEACHER_USER: process.env.NOTEN_TEACHER_USER || null, - NOTEN_TEACHER_PASSWORD: process.env.NOTEN_TEACHER_PASSWORD || null, - NOTEN_FOREIGN_LV_ID: process.env.NOTEN_FOREIGN_LV_ID || null, - - // assistant account for the role matrix: carries only lehre/benotungstool_assistenz - NOTEN_ASSISTENZ_USER: process.env.NOTEN_ASSISTENZ_USER || null, - NOTEN_ASSISTENZ_PASSWORD: process.env.NOTEN_ASSISTENZ_PASSWORD || null, + // a value from --env wins over the suite's .env + config.env = { ...env(), ...config.env }; + return config; }, }; diff --git a/tests/cypress/suites/readme_benotungstool.txt b/tests/cypress/suites/readme_benotungstool.txt index ce6651ed0..9ad66640f 100644 --- a/tests/cypress/suites/readme_benotungstool.txt +++ b/tests/cypress/suites/readme_benotungstool.txt @@ -1,135 +1,87 @@ BENOTUNGSTOOL - TESTSUITE ========================= +The suite deletes and writes notes to the database. Run only on a test instance. -WARNUNG: Die Suite löscht und schreibt Noten in der Datenbank. Starte sie nur gegen eine -Testinstanz. - - -1. WAS HIER LIEGT +1. Files ----------------- -e2e/specs/unit/ Regeln als reine Funktion. Kein Server, keine Datenbank. -e2e/specs/api/noten/ Die API. Ein Test ruft einen Endpunkt auf und prüft die Antwort. -e2e/specs/ui/ Die Oberfläche im Browser. +e2e/specs/unit/ Rules as pure functions. +e2e/specs/api/noten/ The API, called by tests to query an endpoint and verify the response. +e2e/specs/ui/ The user interface in the browser. -support/api/ Die API-Aufrufe an einer Stelle. Spiegelt public/js/api/factory/noten.js. -support/pages/ Die Oberfläche an einer Stelle. Selektoren und Klickwege. -support/helpers/ Testdaten, Fehlermeldungen, Fixture-Reset. -tasks/ Datenbankverbindung und SSH-Tunnel. Läuft in Node, nicht im Browser. -suites/noten.js Verbindet die Suite mit cypress.config.js. -tools/dbCheck.js Prüft die Fixture ohne HTTP. Gutes erstes Kommando bei Problemen. +support/api/ API calls, which mirror public/js/api/factory/noten.js. +support/pages/ The user interface with selectors and click paths. +support/helpers/ Test data, error messages, fixture reset. +tasks/ Database connection in Node, not in the browser. +suites/noten.js Links the suite to cypress.config.js. +suites/.env The suite’s environment variables. +tools/dbCheck.js Checks the fixture without HTTP. A good first command to run if problems arise. -Die Konfiguration steht in cypress.config.js. Die Zugangsdaten stehen in -tests/cypress/.env. +The configuration is located in cypress.config.js. The credentials are stored in two files: +tests/cypress/.env applies to all suites, while tests/cypress/suites/.env applies only to this suite. -2. VORBEREITUNG +2. Preparation --------------- -Einmalig: +Create login credentials in actual .env files: - npm install +tests/cypress/.env.example -> tests/cypress/.env +tests/cypress/suites/.env.example -> tests/cypress/suites/.env -Dann die Zugangsdaten anlegen: +Fill out both files. The comments within them explain each value. - cp tests/cypress/.env.example tests/cypress/.env +- NOTEN_USER is the INSTRUCTOR, not an administrator. An administrator usually does not teach any courses, + so the suite will not find any courses. +- The suite requires a direct connection to the database (NOTEN_DB_*). -Fülle die Datei aus. Die Kommentare darin erklären jeden Wert. Drei Punkte sind wichtig: - -- .env darf nie in Git landen. -- USER_NAME ist der LEKTOR, nicht ein Administrator. Ein Administrator unterrichtet meist nichts, - dann findet die Suite keine Lehrveranstaltung. -- Von einer Arbeitsstation aus brauchst du den SSH-Tunnel. Die Datenbank nimmt nur den - Applikationsserver an. Setze NOTEN_SSH_TUNNEL=true und die drei NOTEN_SSH_*-Werte. - -Beide Seiten müssen auf dieselbe Datenbank zeigen: die Webinstanz (config/system.config.inc.php, -DB_NAME) und die Suite (NOTEN_DB_NAME in .env). Sonst seedet die Suite die eine Datenbank und prüft -gegen die andere. +Both sides must point to the same database: the web instance (config/system.config.inc.php, +DB_NAME) and the suite (NOTEN_DB_NAME in suites/.env). Otherwise, the suite will seed one database and +check against the other. -3. STARTEN +3. Start ---------- - npm run noten:check Fixture prüfen. - npm run cy:noten:api Unit- und API-Tests. - npm run cy:noten:ui Oberfläche in Chrome. - npm run cy:open Cypress interaktiv. + npm run noten:check Check fixture. + npm run noten:api Unit and API tests. + npm run noten:ui User interface in Chrome. + npx cypress open Cypress interactive. -Zwei weitere Skripte prüfen den Punktemodus: +Put more Cypress options after "--". A second --spec replaces the default: - npm run cy:noten:punkte-on erwartet CIS_GESAMTNOTE_PUNKTE = true - npm run cy:noten:punkte-off erwartet CIS_GESAMTNOTE_PUNKTE = false + npm run noten:api -- --spec tests/cypress/e2e/specs/api/noten/noten.frist.cy.js + npm run noten:ui -- --headed --no-exit + npm run noten:api -- --env NOTEN_PUNKTE_MODE=on -Passt die Instanz nicht zur Erwartung, bricht der Lauf sofort ab. - -Die Konfiguration der Instanz wechselt über Profile (tests/cypress/profiles/noten.js). Ein Profil -schreibt application/config/noten.php und die define()-Schalter in config/global.config.inc.php auf -der Instanz um. Nach dem Lauf stellt die Suite beide Dateien wieder her. Dafür braucht .env -zusätzlich NOTEN_REMOTE_ROOT. - - npm run cy:noten:profile jedes Profil, Unit und API, danach der Bericht - npm run cy:noten:profile -- ui final nur die Oberfläche, nur das Profil final - npm run cy:noten:profile -- alle a,b alles, nur die Profile a und b - npm run noten:profil -- status das aktive Profil und die Liste der Profile - npm run noten:profil -- anwenden final ein Profil einschalten, zum Beispiel vor cy:open - npm run noten:profil -- wiederherstellen die Originale zurück - -Jedes Profil schaltet die Freigabemail aus. Nur dann laufen die Freigabetests. - -Ein Lauf ohne Profil stellt zuerst die Originale her. So bleibt die Instanz nach einem abgebrochenen -Lauf nicht auf einem Profil stehen. +tests/cypress/profiles/noten.js describes the configurations in which the suite should run. A +profile specifies the values for application/config/noten.php and the define() switches in +config/global.config.inc.php. The suite tests the configuration it finds. Switching +the instance is not part of the suite. -4. DAS ERGEBNIS ---------------------- - -Am Ende steht eine Tabelle mit vier Spalten. - -Passing Der Test lief und war erfolgreich. -Failing Der Test lief und fand einen Fehler. Nur das ist ein Problem. -Pending Der Test hat sich selbst übersprungen. Das ist kein Fehler. -Skipped Ein Test lief nicht, weil vorher etwas abgebrochen ist. - -Pending ist normal. Ein Test überspringt sich, wenn seine Voraussetzung fehlt. Beispiele: der -Punktemodus ist aus, die Frist ist nicht aktiv, die Freigabemail ist nicht erlaubt. Den Grund -schreibt der Test ins Protokoll. - -Ein Test, der in keinem Profil läuft, ist dagegen eine Lücke. Der Bericht von cy:noten:profile -listet diese Tests am Ende. Ergänze für jeden davon ein Profil. - -Der aktuelle Sollzustand: API und Unit 84 Tests, 69 grün, 15 pending. UI 30 Tests, 18 grün, -12 pending. Kein roter Test. - - -5. WENN ETWAS SCHIEFGEHT +4. Debugging ------------------------ -401 bei allen Tests - Die Webinstanz und .env zeigen auf verschiedene Datenbanken. Siehe Abschnitt 2. +401 in all tests + The web instance and suites/.env likely point to different databases. -"Fixture reset unavailable" - Die Datenbankverbindung fehlt. Prüfe den SSH-Tunnel. Einen Schlüssel mit einem Namen ausserhalb - der OpenSSH-Standardnamen probiert SSH nie automatisch. Trage ihn in NOTEN_SSH_KEY ein. +“Fixture reset unavailable” + The database connection is missing. Check NOTEN_DB_* in tests/cypress/suites/.env. The pg_hba.conf file for the + database must allow access from the machine running Cypress. -Ein Test erwartet eine Fehlermeldung und bekommt eine andere - Die Phrase fehlt in der Datenbank. Lass system/phrasesupdate.php auf dem Server laufen. +A test expects one error message but receives a different one + The phrase is missing from the database -> run system/phrasesupdate.php on the server. -Die Instanz steht nach einem Absturz noch auf einem Profil - npm run noten:profil -- wiederherstellen +The next run won’t start + A Cypress process is still hanging. Terminate it: Stop-Process -Name Cypress -Force -Der nächste Lauf startet nicht - Ein Cypress-Prozess hängt noch. Beende ihn: Stop-Process -Name Cypress -Force - -Ein Screenshot zeigt eine leere Seite - Bei API-Tests ist das normal. Diese Tests öffnen keine Seite. - - -6. KONVENTIONEN +5. Conventions --------------- -- Keine festen Ids im Test. Die Noten kommen aus getNoten, die Regelwerte aus getCisConfig. Eine - andere Installation hat andere Zahlen. -- Warte nach jeder schreibenden Aktion auf den Request, nicht auf die Oberfläche. -- Sprich die Oberfläche über data-cy an, nie über eine CSS-Klasse. -- Setze den Zustand vor dem Test über givenBaseline oder resetNotenState. Verlass dich nie auf den - Zustand, den ein Test davor hinterlassen hat. +- No fixed IDs in the test. The grades come from `getNoten`, the rule values from `getCisConfig`. A + different installation will have different primary keys. +- After every write operation, wait for the request, not for the UI. +- Access the UI via `data-cy`, not via a CSS class. +- Set the state before the test using `givenBaseline` or `resetNotenState`. Never rely on the + state left behind by a previous test. diff --git a/tests/cypress/support/api/notenApi.js b/tests/cypress/support/api/notenApi.js index 2a29e41f6..9c19cc7fa 100644 --- a/tests/cypress/support/api/notenApi.js +++ b/tests/cypress/support/api/notenApi.js @@ -7,19 +7,25 @@ const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten"; // One session for the whole run. Without a session cookie the server checks each Basic-auth request // against LDAP, and many LDAP binds can block all PHP-FPM workers of the instance. cy.login creates the // session once (cy.session, cacheAcrossSpecs) and restores it before each test of every importing spec. -beforeEach(() => cy.login()); - -const authOptions = () => ({ - username: Cypress.env("adminusername"), - password: Cypress.env("adminpassword"), +beforeEach(() => { + const { username, password } = notenAuth(); + cy.login(username, password); }); +/** The lecturer the suite works as. No fallback to USER_NAME: that user belongs to every suite. */ +export const notenAuth = () => { + const username = Cypress.env("NOTEN_USER"); + const password = Cypress.env("NOTEN_PASSWORD"); + if (!username || !password) throw new Error("NOTEN_USER / NOTEN_PASSWORD missing in tests/cypress/suites/.env"); + return { username, password }; +}; + // Basic auth stays on every call as the fallback; with a session cookie the server uses the cookie. const apiGet = (path, qs) => - cy.request({ method: "GET", url: `${NOTEN_API}/${path}`, qs, auth: authOptions(), failOnStatusCode: false }); + cy.request({ method: "GET", url: `${NOTEN_API}/${path}`, qs, auth: notenAuth(), failOnStatusCode: false }); const apiPost = (path, body) => - cy.request({ method: "POST", url: `${NOTEN_API}/${path}`, body, auth: authOptions(), failOnStatusCode: false }); + cy.request({ method: "POST", url: `${NOTEN_API}/${path}`, body, auth: notenAuth(), failOnStatusCode: false }); export const notenApi = { getCisConfig: () => apiGet("getCisConfig"), @@ -71,18 +77,18 @@ export const notenApi = { }), /** - * LDAP-password gated. data -> [{uid, freigabedatum, benotungsdatum}] + * LDAP password-protected data -> [{uid, approval_date, grading_date}] * - * Zuerst eine Sitzung: AuthLDAPLib lädt ldap.php mit require_once. Prüft die Basic-Anmeldung desselben - * Requests schon LDAP, sieht die Passwortprüfung der Freigabe keine Konfiguration und meldet - * "Falsches Passwort". Mit dem Sitzungscookie prüft die Anmeldung kein LDAP. + * First, a session: AuthLDAPLib loads ldap.php using require_once. It checks the Basic authentication for the same + * request against LDAP; if the password check for the release finds no configuration, it reports + * “Incorrect password.” With the session cookie, the login does not check against LDAP. */ saveStudentenNoten: (password, noten, lv_id, sem_kurzbz) => apiGet("getCisConfig").then(() => apiPost("saveStudentenNoten", { password, noten, lv_id, sem_kurzbz })), getNoteByPunkte: (punkte, lv_id, sem_kurzbz) => apiPost("getNoteByPunkte", { punkte, lv_id, sem_kurzbz }), - // Bulk-Pfade antworten 200 und melden Fehler je Zeile in data[uid] + // Bulk paths return a 200 response and report errors on a per-row basis in data[uid] saveNotenvorschlagBulk: (lv_id, sem_kurzbz, noten) => apiPost("saveNotenvorschlagBulk", { lv_id, sem_kurzbz, noten }), @@ -98,15 +104,16 @@ export const notenApi = { export const pruefungenOf = (data, uid) => (data[1] || []).filter((p) => p.student_uid === uid); -/** Antritte in Verlaufsreihenfolge. Specs prüfen position/zaehlt/antritt_nr/terminal, nie den Typ. */ +/** Antritte in chronological order. Check the specs for position/count/start_no/terminal, never the type. */ export const attemptsOf = (data, uid) => [...pruefungenOf(data, uid)].sort((a, b) => Number(a.position) - Number(b.position)); -/** Only the attempts that consume one - excused / "noch nicht eingetragen" / nicht beurteilt do not. */ +/** Only the attempts that consume one - excused / "noch nicht eingetragen" do not. */ export const countingAttemptsOf = (data, uid) => attemptsOf(data, uid).filter((p) => p.zaehlt); /** Legacy projection written for old reports. Asserted in exactly one spec, never used as a rule. */ -export const pruefungenOfTyp = (data, uid, typ) => pruefungenOf(data, uid).filter((p) => p.pruefungstyp_kurzbz === typ); +export const pruefungenOfType = (data, uid, type) => + pruefungenOf(data, uid).filter((p) => p.pruefungstyp_kurzbz === type); export const gradesOf = (data, uid) => (data[3] || {})[uid]; diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index e3fa720da..d2dd5a3f0 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -25,9 +25,8 @@ Cypress.Commands.add("checkPageResources", (url) => { }); /** - * Session-Login für die UI-Specs. - * cy.session hält es über Specs hinweg, damit nicht jeder Test neu anmeldet. - * Ohne Argumente meldet sich der Suite-Benutzer an. + * Session-Login. cy.session hält es über Specs hinweg, damit nicht jeder Test neu anmeldet. + * Ohne Argumente meldet sich USER_NAME an. */ Cypress.Commands.add("login", (username, password) => { const { adminusername, adminpassword } = Cypress.env(); @@ -35,7 +34,7 @@ Cypress.Commands.add("login", (username, password) => { const probe = () => cy.request({ url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID", auth }).its("status").should("eq", 200); - cy.session(["benotungstool-login", auth.username], probe, { + cy.session(["login", auth.username], probe, { cacheAcrossSpecs: true, validate: probe, }); diff --git a/tests/cypress/support/helpers/notenAssistenz.js b/tests/cypress/support/helpers/notenAssistenz.js index 7d9b5fb35..a4d45cbbe 100644 --- a/tests/cypress/support/helpers/notenAssistenz.js +++ b/tests/cypress/support/helpers/notenAssistenz.js @@ -1,23 +1,23 @@ /** - * Das Konto der Assistenz: NOTEN_ASSISTENZ_USER / NOTEN_ASSISTENZ_PASSWORD. + * The assistant's account: NOTEN_ASSISTENZ_USER / NOTEN_ASSISTENZ_PASSWORD. * - * getByStgs liefert einen Studiengang nur in einem Semester mit Studienplan. Die Helfer suchen deshalb - * vom Semester der Suite rückwärts. Vor dem ersten Aufruf braucht der Test cy.clearAllCookies(), sonst - * läuft der Request mit dem Cookie des Suite-Benutzers. + * getByStgs returns a degree program only for a single semester with a curriculum. The assistants therefore + * search backward from the semester of the suite. Before the first call, the test requires cy.clearAllCookies(); otherwise, + * the request will run using the suite user’s cookie. */ import { expectNotenSuccess } from "./notenErrors"; -import { skipWenn } from "./notenConfig"; +import { skipIf } from "./notenConfig"; const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten"; -const SEMESTER_SUCHTIEFE = 6; +const SEMESTER_SEARCH_DEPTH = 6; export const assistenzConfigured = () => Boolean(Cypress.env("NOTEN_ASSISTENZ_USER") && Cypress.env("NOTEN_ASSISTENZ_PASSWORD")); /** Skip, wenn kein Konto der Assistenz konfiguriert ist. */ export const requireAssistenz = (testContext) => - skipWenn( + skipIf( testContext, !assistenzConfigured(), "Übersprungen: NOTEN_ASSISTENZ_USER / NOTEN_ASSISTENZ_PASSWORD fehlen.", @@ -28,72 +28,72 @@ export const assistenzAuth = () => ({ password: Cypress.env("NOTEN_ASSISTENZ_PASSWORD"), }); -const getAlsAssistenz = (path, qs) => +const getAsAssistenz = (path, qs) => cy.request({ method: "GET", url: `${NOTEN_API}/${path}`, qs, auth: assistenzAuth(), failOnStatusCode: false }); /** WS2026 -> [WS2026, SS2026, WS2025, ...] */ -const semesterAbwaerts = (sem) => { - const liste = [sem]; - let typ = sem.slice(0, 2); - let jahr = Number(sem.slice(2, 6)); +const semestersBackwards = (sem) => { + const semesterList = [sem]; + let type = sem.slice(0, 2); + let year = Number(sem.slice(2, 6)); - while (liste.length < SEMESTER_SUCHTIEFE) { - if (typ === "WS") { - typ = "SS"; + while (semesterList.length < SEMESTER_SEARCH_DEPTH) { + if (type === "WS") { + type = "SS"; } else { - typ = "WS"; - jahr -= 1; + type = "WS"; + year -= 1; } - liste.push(`${typ}${jahr}`); + semesterList.push(`${type}${year}`); } - return liste; + return semesterList; }; /** -> { sem, data } für das neueste Semester, in dem die Assistenz Studiengänge sieht, sonst null. */ -export const assistenzKontext = (startSem) => { - const liste = semesterAbwaerts(startSem); +export const assistenzContext = (startSem) => { + const semesterList = semestersBackwards(startSem); - const probiere = (i) => - i >= liste.length + const trySemester = (i) => + i >= semesterList.length ? cy.wrap(null, { log: false }) - : getAlsAssistenz("getBenotungstoolContext", { sem_kurzbz: liste[i] }).then((response) => { - const data = expectNotenSuccess(response, `Kontext der Assistenz in ${liste[i]}`); - return data.studiengaenge.length ? { sem: liste[i], data } : probiere(i + 1); + : getAsAssistenz("getBenotungstoolContext", { sem_kurzbz: semesterList[i] }).then((response) => { + const data = expectNotenSuccess(response, `Kontext der Assistenz in ${semesterList[i]}`); + return data.studiengaenge.length ? { sem: semesterList[i], data } : trySemester(i + 1); }); - return probiere(0); + return trySemester(0); }; /** -> { sem, lvId, lehreinheiten } für eine LV der Assistenz mit Lehreinheiten in der Datenbank, sonst null. */ -export const assistenzLvMitLehreinheiten = (startSem) => - assistenzKontext(startSem).then((kontext) => { - if (!kontext) return null; +export const assistenzLvWithLehreinheiten = (startSem) => + assistenzContext(startSem).then((result) => { + if (!result) return null; - const { sem } = kontext; - const studiengaenge = kontext.data.studiengaenge; + const { sem } = result; + const studiengaenge = result.data.studiengaenge; - const ersteLvMitLehreinheiten = (lvs, j) => + const firstLvWithLehreinheiten = (lvs, j) => j >= lvs.length ? cy.wrap(null, { log: false }) : cy - .task("noten:db:lehreinheitenDerLv", { lvId: lvs[j].lehrveranstaltung_id, semKurzbz: sem }) + .task("noten:db:lehreinheitenOfLv", { lvId: lvs[j].lehrveranstaltung_id, semKurzbz: sem }) .then((ids) => ids.length ? { sem, lvId: lvs[j].lehrveranstaltung_id, lehreinheiten: ids.map(String).sort() } - : ersteLvMitLehreinheiten(lvs, j + 1), + : firstLvWithLehreinheiten(lvs, j + 1), ); - const probiereStudiengang = (i) => + const tryStudiengang = (i) => i >= studiengaenge.length ? cy.wrap(null, { log: false }) - : getAlsAssistenz("getLvForStudiengang", { + : getAsAssistenz("getLvForStudiengang", { studiengang_kz: studiengaenge[i].studiengang_kz, sem_kurzbz: sem, }) .then((response) => - ersteLvMitLehreinheiten(expectNotenSuccess(response, "LVs des Studiengangs"), 0), + firstLvWithLehreinheiten(expectNotenSuccess(response, "LVs des Studiengangs"), 0), ) - .then((ziel) => ziel || probiereStudiengang(i + 1)); + .then((target) => target || tryStudiengang(i + 1)); - return probiereStudiengang(0); + return tryStudiengang(0); }); diff --git a/tests/cypress/support/helpers/notenConfig.js b/tests/cypress/support/helpers/notenConfig.js index 5fcf2a1de..660e0ce24 100644 --- a/tests/cypress/support/helpers/notenConfig.js +++ b/tests/cypress/support/helpers/notenConfig.js @@ -1,65 +1,65 @@ /** - * Konfigurationsabhängige Specs. + * Configuration-dependent specs. * - * CIS_GESAMTNOTE_PUNKTE ist ein PHP define() in config/global.config.inc.php und damit eine - * Eigenschaft der INSTANZ, nicht des Requests - ein Testlauf kann den Modus nicht umschalten. - * Die Suite deckt deshalb beide Modi ab und überspringt jeweils den unpassenden Teil. + * CIS_GESAMTNOTE_PUNKTE is a PHP define() in config/global.config.inc.php and is therefore a + * property of the INSTANCE, not of the request. A test run cannot switch modes. + * The suite therefore covers both modes and skips the irrelevant part in each case. * - * Für die Pipeline heisst das: zwei Jobs gegen je eine Instanz. Damit ein Job nicht grün meldet, - * weil in Wahrheit alles übersprungen wurde, gibt NOTEN_PUNKTE_MODUS ('on'|'off') die Erwartung - * vor - passt die Instanz nicht dazu, scheitert der Lauf sofort und laut. + * For the pipeline, this means: two jobs, each against a single instance. To prevent a job from reporting a pass + * when in reality everything was skipped, NOTEN_PUNKTE_MODE (‘on’|'off') specifies the expectation. + * If the instance does not match this, the run fails immediately and with a clear error message. */ /** - * Der einzige Weg, einen Test zu überspringen. `bedingung` wahr -> überspringen, `grund` sagt warum. - * In it(function(){...}) / beforeEach(function(){...}) aufrufen, nie in einer Pfeilfunktion: eine - * Pfeilfunktion bindet kein `this` und erreicht test.skip() nicht. + * The only way to skip a test. `condition` true -> skip; `reason` explains why. + * Call within it(function(){...}) / beforeEach(function(){...}), never in an arrow function: an + * arrow function does not bind `this` and cannot access test.skip(). * - * Die require*-Helfer unten nennen je eine fachliche Voraussetzung und rufen ihrerseits skipWenn. + * The `require*` helpers below each specify a business requirement and, in turn, call `skipIf`. */ -export const skipWenn = (testContext, bedingung, grund) => { - if (!bedingung) return; - Cypress.log({ name: "skip", message: grund }); +export const skipIf = (testContext, condition, reason) => { + if (!condition) return; + Cypress.log({ name: "skip", message: reason }); testContext.skip(); }; /** Ist der Punktemodus auf dieser Instanz aktiv? */ -export const punkteModus = (ctx) => Boolean(ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE); +export const punkteMode = (ctx) => Boolean(ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE); /** - * In before() aufrufen: prüft die Instanz gegen NOTEN_PUNKTE_MODUS. Ohne gesetzte Variable + * In before() aufrufen: prüft die Instanz gegen NOTEN_PUNKTE_MODE. Ohne gesetzte Variable * (lokaler Lauf) wird nur geloggt. */ -export const assertPunkteModus = (ctx) => { - const erwartet = String(Cypress.env("NOTEN_PUNKTE_MODUS") || "").toLowerCase(); - const ist = punkteModus(ctx); +export const assertPunkteMode = (ctx) => { + const expectedMode = String(Cypress.env("NOTEN_PUNKTE_MODE") || "").toLowerCase(); + const actualMode = punkteMode(ctx); - if (erwartet !== "on" && erwartet !== "off") { - cy.log(`CIS_GESAMTNOTE_PUNKTE = ${ist} (NOTEN_PUNKTE_MODUS nicht gesetzt)`); + if (expectedMode !== "on" && expectedMode !== "off") { + cy.log(`CIS_GESAMTNOTE_PUNKTE = ${actualMode} (NOTEN_PUNKTE_MODE nicht gesetzt)`); return; } expect( - ist, - `NOTEN_PUNKTE_MODUS=${erwartet}, die Instanz steht aber auf CIS_GESAMTNOTE_PUNKTE=${ist}. ` + + actualMode, + `NOTEN_PUNKTE_MODE=${expectedMode}, die Instanz steht aber auf CIS_GESAMTNOTE_PUNKTE=${actualMode}. ` + "Der Lauf würde sonst grün melden, obwohl der halbe Umfang übersprungen wurde. " + "Flag sitzt in config/global.config.inc.php - cis.config.inc.php wird zu spät geladen.", - ).to.eq(erwartet === "on"); + ).to.eq(expectedMode === "on"); }; /** Skip, wenn der Punktemodus aus ist. Als erste Zeile in beforeEach(function(){...}) aufrufen. */ -export const requirePunkteModus = (testContext, ctx) => - skipWenn(testContext, !punkteModus(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aus."); +export const requirePunkteMode = (testContext, ctx) => + skipIf(testContext, !punkteMode(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aus."); /** * Skip, wenn der Schlüssel aus getCisConfig nicht den Zweig trägt, den der Test prüft. Ein Profil aus * tests/cypress/profiles/noten.js schaltet den anderen Zweig ein. */ -export const requireKonfiguration = (testContext, ctx, key, wert) => - skipWenn( +export const requireConfig = (testContext, ctx, key, value) => + skipIf( testContext, - ctx.cisConfig[key] !== wert, - `Übersprungen: ${key} ist ${JSON.stringify(ctx.cisConfig[key])}, der Test braucht ${JSON.stringify(wert)}.`, + ctx.cisConfig[key] !== value, + `Übersprungen: ${key} ist ${JSON.stringify(ctx.cisConfig[key])}, der Test braucht ${JSON.stringify(value)}.`, ); /** @@ -67,12 +67,12 @@ export const requireKonfiguration = (testContext, ctx, key, wert) => * endet die Kette für das Werkzeug vor dem kommissionellen Antritt. */ export const requireWiederholung = (testContext, ctx) => { - const ab = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT ?? null; - const anlegbar = - ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF === false && ab !== null - ? Math.min(ctx.maxAntritte, ab - 1) + const fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT ?? null; + const creatable = + ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF === false && fromAntritt !== null + ? Math.min(ctx.maxAntritte, fromAntritt - 1) : ctx.maxAntritte; - skipWenn(testContext, anlegbar < 2, `Übersprungen: das Werkzeug legt nur ${anlegbar} Antritt an.`); + skipIf(testContext, creatable < 2, `Übersprungen: das Werkzeug legt nur ${creatable} Antritt an.`); }; /** @@ -80,16 +80,16 @@ export const requireWiederholung = (testContext, ctx) => { * CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF verbietet die Anlage. */ export const requireKommissionellerAntritt = (testContext, ctx) => { - const ab = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT ?? null; - const anlegbar = ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF !== false; - const moeglich = anlegbar && ab !== null && ab >= 2 && ab <= ctx.maxAntritte; - skipWenn( + const fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT ?? null; + const creatable = ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF !== false; + const possible = creatable && fromAntritt !== null && fromAntritt >= 2 && fromAntritt <= ctx.maxAntritte; + skipIf( testContext, - !moeglich, - `Übersprungen: kein anlegbarer kommissioneller Antritt (ab ${JSON.stringify(ab)}, anlegen ${anlegbar}).`, + !possible, + `Übersprungen: kein anlegbarer kommissioneller Antritt (ab ${JSON.stringify(fromAntritt)}, anlegen ${creatable}).`, ); }; /** Skip, wenn der Punktemodus an ist. */ -export const requireNotenModus = (testContext, ctx) => - skipWenn(testContext, punkteModus(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aktiv."); +export const requireNotenMode = (testContext, ctx) => + skipIf(testContext, punkteMode(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aktiv."); diff --git a/tests/cypress/support/helpers/notenErrors.js b/tests/cypress/support/helpers/notenErrors.js index d86f0ed1a..4987d6e82 100644 --- a/tests/cypress/support/helpers/notenErrors.js +++ b/tests/cypress/support/helpers/notenErrors.js @@ -1,8 +1,8 @@ /** - * Assertions für die Noten-Fehlerhülle. + * Assertions for the grade error message. * - * Die Fehler tragen keinen errorCode, nur eine lokalisierte Meldung - daher der Abgleich gegen die - * de/en-Templates aus system/phrasesupdate.php. Ein errorCode serverseitig würde das erübrigen. + * The errors do not have an errorCode, only a localized message—hence the comparison against the + * de/en templates from system/phrasesupdate.php. An errorCode on the server side would make this unnecessary. */ const PHRASES = { diff --git a/tests/cypress/support/helpers/notenReset.js b/tests/cypress/support/helpers/notenReset.js index af3ff1c06..57ee9573d 100644 --- a/tests/cypress/support/helpers/notenReset.js +++ b/tests/cypress/support/helpers/notenReset.js @@ -1,9 +1,10 @@ /** - * Fixture-Reset ausschliesslich über eine direkte DB-Verbindung - ein HTTP-Endpunkt, der - * Notendaten löscht, ist durch keine Absicherung so sicher wie sein Nichtvorhandensein. - * Von der Workstation aus braucht das den SSH-Tunnel (cypress/tasks/sshTunnel.js). + * Reset fixtures exclusively via a direct database connection, an HTTP endpoint that + * deletes grade data is never as secure as its absence, no matter what safeguards are in place. */ +import { notenAuth } from "../api/notenApi"; + let available = null; /** Probes the DB once per run. Never throws; the caller decides how loudly to fail. */ @@ -18,13 +19,11 @@ export const resolveResetStrategy = () => { }; export const describeFailure = (state) => - ` ${state.reason}\n\n` + - " Needs a database connection. From a workstation: NOTEN_SSH_TUNNEL=true plus\n" + - " NOTEN_SSH_HOST / NOTEN_SSH_USER (and NOTEN_SSH_KEY, unless an agent holds the key)."; + ` ${state.reason}\n\n` + " Needs a database connection: NOTEN_DB_* in tests/cypress/suites/.env."; /** * uid to stamp as mitarbeiter_uid / freigabevon_uid. - * Not Cypress.env("adminusername"): LDAP accepts "Demolektor1" but tbl_benutzer.uid is + * Not Cypress.env("NOTEN_USER"): LDAP accepts "Demolektor1" but tbl_benutzer.uid is * "demolektor1" and the FKs are case sensitive. */ let cachedAuthUid = null; @@ -36,7 +35,7 @@ const authUid = () => { .request({ method: "GET", url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID", - auth: { username: Cypress.env("adminusername"), password: Cypress.env("adminpassword") }, + auth: notenAuth(), failOnStatusCode: false, }) .then((response) => { diff --git a/tests/cypress/support/helpers/notenScenario.js b/tests/cypress/support/helpers/notenScenario.js index 8a71f6200..be12828f9 100644 --- a/tests/cypress/support/helpers/notenScenario.js +++ b/tests/cypress/support/helpers/notenScenario.js @@ -5,10 +5,10 @@ import { expectNotenSuccess } from "./notenErrors"; import { resetNotenState, seedBaseline } from "./notenTestData"; /** - * Neuer Antritt (ohne pruefung_id -> validatePruefungAdd). Kein typ auf der Leitung. + * New entry (without prüfung_id -> validatePruefungAdd). No type on the line. * - * punkte bleibt null: mit CIS_GESAMTNOTE_PUNKTE würde ein punkte >= 0 die Note aus dem - * Notenschlüssel neu ableiten und die zu testende Note überschreiben. + * Points remain zero: with CIS_GESAMTNOTE_PUNKTE, a points value >= 0 would derive the grade anew from the + * grading scale and overwrite the grade being tested. */ export const addPruefung = (context, student, { note, datum }) => notenApi.saveStudentPruefung({ @@ -36,8 +36,8 @@ export const editPruefung = (context, student, { pruefungId, note, datum }) => }); /** - * Liest den Zustand über die API (getStudentenNoten): was der Server MELDET. - * Das Gegenstück ist readLvGesamtnoteViaDb - es liest, was wirklich gespeichert ist. + * Reads the status via the API (getStudentenNoten): what the server REPORTS. + * The counterpart is readLvGesamtnoteViaDb, it reads what is actually stored. */ export const readStateViaApi = (context) => notenApi diff --git a/tests/cypress/support/helpers/notenTestData.js b/tests/cypress/support/helpers/notenTestData.js index dbfeac156..fa045b725 100644 --- a/tests/cypress/support/helpers/notenTestData.js +++ b/tests/cypress/support/helpers/notenTestData.js @@ -1,11 +1,11 @@ /** - * Runtime discovery + Fixture-Helfer. + * Runtime discovery + fixture helpers. * - * Keine fixen Ids: maxAntritte und die Sondernoten-PKs stehen in Server-Config, die nicht im Repo - * liegt, und werden daher zur Laufzeit über die API gelesen. Override: NOTEN_SEM / NOTEN_LV_ID. + * No fixed IDs: maxAntritte and the special grade PKs are stored in the server configuration, which is not in the repo + * and are therefore read at runtime via the API. Override: NOTEN_SEM / NOTEN_LV_ID. */ -import { notenApi } from "../api/notenApi"; +import { notenApi, notenAuth } from "../api/notenApi"; import { expectNotenSuccess } from "./notenErrors"; // the client rule; it now only reads back what the server derived, so a mismatch is a config bug import { maxAntrittCount as computeMaxAntritte } from "../../../../public/js/components/Cis/Benotungstool/notenRules.js"; @@ -18,7 +18,7 @@ import { performSeedZeugnisnote, resolveResetStrategy, } from "./notenReset"; -import { assertPunkteModus } from "./notenConfig"; +import { assertPunkteMode } from "./notenConfig"; const BEZ_ENTSCHULDIGT = "entschuldigt"; const BEZ_NOCH_NICHT = "Noch nicht eingetragen"; @@ -65,35 +65,27 @@ export const fristHasPassed = (semKurzbz, ssConfig = { month: 11, day: 15 }, wsC * @param {string} type "SS" or "WS" * @returns {Cypress.Chainable<{semKurzbz: string, lvId: number}|null>} */ -export const lehrsemesterMitAbgelaufenerFrist = (type = "SS", ssConfig, wsConfig) => { - const jahr = new Date().getFullYear(); - const kandidaten = []; - for (let y = jahr; y >= jahr - 6; y -= 1) { +export const teachingSemesterWithExpiredFrist = (type = "SS", ssConfig, wsConfig) => { + const year = new Date().getFullYear(); + const candidates = []; + for (let y = year; y >= year - 6; y -= 1) { const sem = `${type}${y}`; - if (fristHasPassed(sem, ssConfig, wsConfig)) kandidaten.push(sem); + if (fristHasPassed(sem, ssConfig, wsConfig)) candidates.push(sem); } - const probiere = (i) => { - if (i >= kandidaten.length) return cy.wrap(null, { log: false }); + const trySemester = (i) => { + if (i >= candidates.length) return cy.wrap(null, { log: false }); - return notenApi.getBenotungstoolContext(kandidaten[i]).then((response) => { + return notenApi.getBenotungstoolContext(candidates[i]).then((response) => { const lvs = (response.body && response.body.data && response.body.data.lehrveranstaltungen) || []; if (lvs.length > 0) { - return { semKurzbz: kandidaten[i], lvId: lvs[0].lehrveranstaltung_id }; + return { semKurzbz: candidates[i], lvId: lvs[0].lehrveranstaltung_id }; } - return probiere(i + 1); + return trySemester(i + 1); }); }; - return probiere(0); -}; - -export const semesterWithPastFrist = (type = "SS") => { - const year = new Date().getFullYear(); - for (let y = year; y >= year - 6; y -= 1) { - if (fristHasPassed(`${type}${y}`)) return `${type}${y}`; - } - return `${type}${year - 6}`; + return trySemester(0); }; // --- discovery --- @@ -107,7 +99,7 @@ const resolveSemester = () => { method: "GET", url: "/index.ci.php/api/frontend/v1/organisation/Studiensemester/getAll", qs: { order: "DESC" }, - auth: { username: Cypress.env("adminusername"), password: Cypress.env("adminpassword") }, + auth: notenAuth(), failOnStatusCode: false, }) .then((response) => { @@ -157,7 +149,7 @@ export const loadNotenContext = () => { context.maxAntritte = computeMaxAntritte(context.cisConfig); // ein Lauf, der den falschen Konfigurationsmodus erwartet, soll hier scheitern und nicht // alles stillschweigend überspringen - assertPunkteModus(context); + assertPunkteMode(context); return notenApi.getNoten(); }) .then((response) => { @@ -190,10 +182,10 @@ export const loadNotenContext = () => { expect(context.gradeNotes.length, "need two ordinary grades to vary one on edit").to.be.greaterThan(1); // administrative note the editor list excludes ("intern angerechnet" / "nicht zugelassen") - const nichtLehre = noten.find((n) => n.lehre === false); + const notLehre = noten.find((n) => n.lehre === false); // as a ZEUGNISnote this one locks the LV note, in the client and in the server - const nichtUeberschreibbar = noten.find((n) => n.lkt_ueberschreibbar === false); + const notUeberschreibbar = noten.find((n) => n.lkt_ueberschreibbar === false); // Anrechnungen block every Prüfung for the LV - keyed on the ZEUGNISnote const angerechnet = byBezeichnung("angerechnet"); @@ -202,9 +194,9 @@ export const loadNotenContext = () => { // An attempt chain needs grades by meaning, not by index: a positive grade closes the // chain, so a repeat after "Sehr Gut" is not a test case but an impossible flow. const abschliessend = (context.cisConfig.NOTEN_ABSCHLIESSEND || []).map(String); - const inSkala = (n) => Number(n.note) >= 1 && Number(n.note) <= 5; - const negativNoten = usable.filter((n) => !n.positiv && inSkala(n)).map((n) => n.note); - const positivNoten = usable.filter((n) => n.positiv && inSkala(n)).map((n) => n.note); + const inScale = (n) => Number(n.note) >= 1 && Number(n.note) <= 5; + const negativNoten = usable.filter((n) => !n.positiv && inScale(n)).map((n) => n.note); + const positivNoten = usable.filter((n) => n.positiv && inScale(n)).map((n) => n.note); const verbesserbar = positivNoten.filter((n) => !abschliessend.includes(String(n))); expect(negativNoten.length, "need a negative grade to build an attempt chain").to.be.greaterThan(0); @@ -218,8 +210,8 @@ export const loadNotenContext = () => { bestnote: positivNoten.find((n) => abschliessend.includes(String(n))) || null, entschuldigt: entschuldigt.note, nochNichtEingetragen: nochNicht.note, - nichtLehre: nichtLehre ? nichtLehre.note : null, - nichtUeberschreibbar: nichtUeberschreibbar ? nichtUeberschreibbar.note : null, + notLehre: notLehre ? notLehre.note : null, + notUeberschreibbar: notUeberschreibbar ? notUeberschreibbar.note : null, angerechnet: angerechnet ? angerechnet.note : null, internAngerechnet: internAngerechnet ? internAngerechnet.note : null, }; @@ -249,11 +241,11 @@ export const loadNotenContext = () => { export const resetNotenState = (context, studentUids) => performReset(context, studentUids || context.studentUids); /** - * Baseline für Antritt 1: freigegebene LV-Note PLUS die Prüfungszeile dazu - das ist, was die - * Freigabe produziert. Ohne die Zeile zählt die LV-Note nur solange gar kein Termin existiert, und - * der erste hinzugefügte Antritt würde Antritt 1 ersetzen statt Antritt 2 zu werden. + * Baseline for Antritt 1: approved course grade PLUS the corresponding exam row—that is what the + * approval generates. Without that row, the course grade only counts as long as no date exists, and + * the first attempt added would replace Attempt 1 instead of becoming Attempt 2. * - * `erstantritt: false` (bzw. `freigegeben: false`) seedet die Altdaten-Form ohne diese Zeile. + * `first_attempt: false` (or `approved: false`) seeds the legacy data form without this line. */ export const seedBaseline = (context, student, options = {}) => { expect(student, "seedBaseline braucht das Studierenden-Objekt aus context.students, nicht die uid").to.be.an( @@ -263,13 +255,13 @@ export const seedBaseline = (context, student, options = {}) => { // Defaults to a NEGATIVE grade: only after one may another attempt follow. Pass // context.notes.bestnote explicitly to close the chain. const note = options.note !== undefined ? options.note : context.notes.negativ; - // Mit CIS_GESAMTNOTE_FREIGABE_FINAL ist eine freigegebene Note endgültig. Ohne ausdrückliches - // freigegeben sät die Baseline dann eine offene Note, damit ein Test weitere Termine anlegen kann. + // With CIS_GESAMTNOTE_FREIGABE_FINAL, a released grade is final. Without an explicit + // release, the baseline sets the grade to “open” so that a test can create additional dates. const freigegeben = options.freigegeben !== undefined ? options.freigegeben : context.cisConfig.CIS_GESAMTNOTE_FREIGABE_FINAL !== true; - // Antritt 1 fehlt nur bei ausdrücklichem erstantritt: false oder freigegeben: false + // Start 1 is missing only if the initial start is explicitly set to false or enabled: false const erstantritt = options.erstantritt !== undefined ? options.erstantritt : options.freigegeben !== false; return performSeed(context, student.uid, { @@ -285,26 +277,27 @@ export const seedBaseline = (context, student, options = {}) => { lehreinheitId: student.lehreinheit_id, note, datum: baselineDate(context), - typ: "Termin1", // legacy projection of Antritt 1; the rules never read it back + type: "Termin1", // legacy projection of Antritt 1; the rules never read it back }).then(() => seeded); }); }; /** -> { pruefungId, note, datum, typ }. For attempt states the API cannot build. */ -export const seedPruefung = (context, student, { note, datum, typ }) => +export const seedPruefung = (context, student, { note, datum, type }) => performSeedPruefung(context, student.uid, { lehreinheitId: student.lehreinheit_id, note, datum, - typ, + type, }); -/** Zeugnisnote setzen. Nur die Studierendenverwaltung schreibt sie, kein Endpunkt dieses Tools. */ +/** Set the grade on the transcript. Only the student administration system (stv) enters it, + * this tool does not handle that. */ export const seedZeugnisnote = (context, studentUid, note) => performSeedZeugnisnote(context, studentUid, { note }); /** - * Liest die LV-Note direkt aus der DATENBANK: was wirklich gespeichert ist, ohne den - * freigabedatum-Filter von getLvGesamtNoten. Das Gegenstück ist readStateViaApi. + * Reads the course grade directly from the DB: what is actually stored, without the + * “release date” filter from getLvGesamtNoten. The counterpart is readStateViaApi. */ export const readLvGesamtnoteViaDb = (context, studentUid) => performRead(context, studentUid); diff --git a/tests/cypress/support/pages/benotungstool.po.js b/tests/cypress/support/pages/benotungstool.po.js index daa7be82d..69689ce22 100644 --- a/tests/cypress/support/pages/benotungstool.po.js +++ b/tests/cypress/support/pages/benotungstool.po.js @@ -1,23 +1,24 @@ +import { notenAuth } from "../api/notenApi"; import { waitForOk } from "../helpers/network"; /** - * Page object für das Benotungstool. + * Page object for the grading tool. * - * Selektoren sind data-cy-Attribute aus Benotungstool.js. Die Tabelle ist Tabulator: Zeilen tragen - * data-cy="student-row-", Zellen das von Tabulator gesetzte tabulator-field, und der - * Freigabestatus steckt als data-state am Zellinhalt statt am Icon. + * Selectors are data-cy attributes from Benotungstool.js. The table is a tabulator: rows contain + * data-cy=“student-row-”, cells contain the tabulator-field set by the tabulator, and the + * approval status is stored as data-state in the cell content rather than in the icon. * - * Nach jeder schreibenden Aktion wird auf den zugehörigen Request gewartet (waitForOk), nicht auf - * eine DOM-Änderung - sonst prüfen die Specs gegen den Stand vor der Antwort. Die submit-Methoden - * warten nicht; mit ihnen prüft ein Test die Ablehnung durch den Server. + * After every write operation, the system waits for the corresponding request (waitForOk), not for + * a DOM change. Otherwise, the specs would check against the state prior to the response. The submit methods + * do not wait; a test uses them to check for rejection by the server. */ const TABLE_TIMEOUT = 60_000; const API = "**/api/frontend/v1/Noten"; /** - * Exakter Textvergleich für Optionslisten. Nötig, weil die Notenbezeichnungen einander enthalten: - * contains("Gut") trifft zuerst "Sehr Gut", contains("Genügend") zuerst "Nicht Genügend". + * Exact text comparison for option lists. This is necessary because the grade labels are subsets of one another: + * contains(“Gut”) matches “Sehr Gut” first, and contains(‘Genügend’) matches “Nicht Genügend” first. */ const exactText = (text) => new RegExp(`^\\s*${String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`); @@ -40,20 +41,21 @@ class BenotungstoolPage { }; /** - * Deep-Link auf LV + Semester - die vier Dropdowns müssen dafür nicht bedient werden. - * Die Spaltenaufteilung wird vor dem Laden gesetzt: die Komponente liest den localStorage beim - * Initialisieren, und vor dem ersten visit gehört er noch about:blank. + * Deep link to LV + Semester. The four dropdown menus do not need to be used for this. + * The column layout is set before loading: the component reads localStorage during + * initialization, and before the first visit, it still belongs to about:blank. */ - visit = (ctx, { spalten = "antritt" } = {}) => + visit = (ctx, { columns = "antritt" } = {}) => cy.visit(`/cis.php/Cis/Benotungstool/${ctx.semKurzbz}/${ctx.lvId}`, { onBeforeLoad(win) { - win.localStorage.setItem("notenToolPruefungsspalten", spalten); + win.localStorage.setItem("notenToolPruefungsspalten", columns); win.localStorage.removeItem("notenToolStickyCols"); }, }); visitAndWaitForTable = (ctx, options) => { - cy.login(); + const { username, password } = notenAuth(); + cy.login(username, password); this.setupIntercepts(); this.visit(ctx, options); @@ -70,13 +72,14 @@ class BenotungstoolPage { getCell = (uid, field) => this.getRow(uid).find(`[tabulator-field='${field}']`); getFreigabeState = (uid) => this.getCell(uid, "freigegeben").find("[data-cy='freigabe-state']"); - getPruefungCell = (uid, spalte) => this.getCell(uid, spalte).find("[data-cy='pruefung-cell']"); + getPruefungCell = (uid, column) => this.getCell(uid, column).find("[data-cy='pruefung-cell']"); getUebernehmenButton = (uid) => this.getCell(uid, "übernehmen").find("[data-cy='btn-uebernehmen']"); - getPruefungAddButton = (uid, spalte) => this.getCell(uid, spalte).find("[data-cy='btn-pruefung-add']"); - getPruefungEditButton = (uid, spalte) => this.getCell(uid, spalte).find("[data-cy='btn-pruefung-edit']"); + getPruefungAddButton = (uid, column) => this.getCell(uid, column).find("[data-cy='btn-pruefung-add']"); + getPruefungEditButton = (uid, column) => this.getCell(uid, column).find("[data-cy='btn-pruefung-edit']"); + getBestandenHint = (uid, column) => this.getCell(uid, column).find("[data-cy='pruefung-bestanden']"); getPruefungModal = () => cy.get("[data-cy='modal-pruefung']"); - getNeuePruefungModal = () => cy.get("[data-cy='modal-neue-pruefung']"); + getNewPruefungModal = () => cy.get("[data-cy='modal-neue-pruefung']"); getFreigabeModal = () => cy.get("[data-cy='modal-freigabe']"); getUebernahmeModal = () => cy.get("[data-cy='modal-uebernahme']"); getNotenImportModal = () => cy.get("[data-cy='modal-noten-import']"); @@ -99,131 +102,130 @@ class BenotungstoolPage { ? this.getCell(uid, "hoechsterAntritt").invoke("text").invoke("trim").should("eq", "") : this.getCell(uid, "hoechsterAntritt").should("contain.text", String(count)); - expectPruefung = (uid, spalte, { note, antritt } = {}) => { + expectPruefung = (uid, column, { note, antritt } = {}) => { // je Assertion neu abfragen, damit Cypress ein Re-Render der Zelle erneut versucht if (note !== undefined) { - this.getPruefungCell(uid, spalte).should("have.attr", "data-note", String(note)); + this.getPruefungCell(uid, column).should("have.attr", "data-note", String(note)); } if (antritt !== undefined) { - this.getPruefungCell(uid, spalte).should("have.attr", "data-attempt", String(antritt)); + this.getPruefungCell(uid, column).should("have.attr", "data-attempt", String(antritt)); } }; - expectKeinePruefung = (uid, spalte) => - this.getCell(uid, spalte).find("[data-cy='pruefung-cell']").should("not.exist"); + expectNoPruefung = (uid, column) => this.getCell(uid, column).find("[data-cy='pruefung-cell']").should("not.exist"); - expectKeineAntrittsspalte = (nr) => cy.get(`[tabulator-field='antritt_${nr}']`).should("not.exist"); + expectNoAntrittColumn = (nr) => cy.get(`[tabulator-field='antritt_${nr}']`).should("not.exist"); /** - * Der Server lehnt eine Speicherung ab, und ein Toast zeigt seine Meldung. Die Meldung kommt aus - * der Antwort, deshalb schreibt der Test keine Phrase fest. + * The server refuses to save the data, and a toast message displays its message. The message comes from + * the response, so the test does not set a phrase. */ - expectAbgelehnt = (alias) => + expectRejected = (alias) => cy.wait(alias).then(({ response }) => { expect(response.statusCode, `${alias} lehnt ab`).to.not.eq(200); - const meldung = (response.body?.errors ?? [])[0]?.message; - expect(meldung, `Meldung von ${alias}`).to.be.a("string").and.not.be.empty; - cy.get(".p-toast-message", { timeout: TABLE_TIMEOUT }).should("contain.text", meldung); + const errorMessage = (response.body?.errors ?? [])[0]?.message; + expect(errorMessage, `Meldung von ${alias}`).to.be.a("string").and.not.be.empty; + cy.get(".p-toast-message", { timeout: TABLE_TIMEOUT }).should("contain.text", errorMessage); }); - /** Ein Sammelpfad lehnt eine Zeile ab: HTTP 200, die Meldung steht in data[uid] und im Toast. */ - expectZeileAbgelehnt = (alias, uid) => + /** A collection path rejects a row: HTTP 200; the message is stored in data[uid] and in the toast. */ + expectRowRejected = (alias, uid) => cy.wait(alias).then(({ response }) => { expect(response.statusCode, `${alias} antwortet`).to.eq(200); - const meldung = response.body?.data?.[uid]; - expect(meldung, `Ablehnung der Zeile ${uid}`).to.be.a("string").and.not.be.empty; - cy.get(".p-toast-message", { timeout: TABLE_TIMEOUT }).should("contain.text", meldung); + const errorMessage = response.body?.data?.[uid]; + expect(errorMessage, `Ablehnung der Zeile ${uid}`).to.be.a("string").and.not.be.empty; + cy.get(".p-toast-message", { timeout: TABLE_TIMEOUT }).should("contain.text", errorMessage); }); - /** Nach einer ganz abgelehnten Aktion meldet die Oberfläche keinen Erfolg. */ - expectKeinErfolg = () => cy.get(".p-toast-message-success").should("not.exist"); + /** After an action is completely rejected, the interface reports that it was unsuccessful. */ + expectNoSuccess = () => cy.get(".p-toast-message-success").should("not.exist"); - /** Eine Warnung des Clients. Warnungen haben keine Ablaufzeit. */ - expectWarnung = (text) => cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("contain.text", text); + /** A client warning. Warnings do not have an expiration time. */ + expectWarning = (text) => cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("contain.text", text); - expectWarnungen = (anzahl) => - cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("have.length.at.least", anzahl); + expectWarnings = (minCount) => + cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("have.length.at.least", minCount); - /** Die uids des letzten Requests eines Sammelpfads. feld: "noten" oder "pruefungen" */ - gesendeteUids = (alias, feld) => + /** The UIDs of the last request in a collection path. Field: “noten” or “pruefungen” */ + sentUids = (alias, bodyField) => cy .get(alias) - .its(`request.body.${feld}`) - .then((zeilen) => zeilen.map((z) => z.uid)); + .its(`request.body.${bodyField}`) + .then((sentRows) => sentRows.map((z) => z.uid)); // --- Notenvorschlag -------------------------------------------------------------------------- - /** Öffnet den Tabulator-Listeneditor der Vorschlagsspalte und wählt die Bezeichnung. */ + /** Opens the tab-separated list editor for the suggestion column and selects the label. */ setNotenvorschlag = (uid, bezeichnung) => { this.getCell(uid, "note_vorschlag").click(); cy.get(".tabulator-edit-list-item").contains(bezeichnung).click(); }; - /** Übernehmen fragt zuerst das Benotungsdatum. Ohne `datum` bleibt der Vorschlag des Dialogs. */ + /** “Übernehmen” first asks for the grading date. If `datum` is omitted, the dialog's default value is used. */ submitUebernahme = (uid, { datum } = {}) => { - this.schliesseToasts(); + this.closeToasts(); this.getUebernehmenButton(uid).click(); this.getUebernahmeModal().should("be.visible"); - if (datum) this.setDatum("uebernahme-datum", datum); + if (datum) this.setDate("uebernahme-datum", datum); cy.get("[data-cy='uebernahme-submit']").click(); }; - uebernehmen = (uid, optionen) => { - this.submitUebernahme(uid, optionen); + uebernehmen = (uid, options) => { + this.submitUebernahme(uid, options); waitForOk("@saveNotenvorschlag"); this.getUebernahmeModal().should("not.be.visible"); }; // --- Prüfungen ------------------------------------------------------------------------------- - /** PrimeVue hängt seine Panels an den body, daher nicht innerhalb des Modals suchen. */ + /** PrimeVue attaches its panels to the `body`, so don't search inside the modal. */ selectDropdownOption = (dataCy, label) => { cy.get(`[data-cy='${dataCy}']`).click(); cy.contains(".p-dropdown-panel .p-dropdown-item", exactText(label)).click(); }; - setDatum = (dataCy, ddmmyyyy) => cy.get(`[data-cy='${dataCy}'] input`).first().clear().type(`${ddmmyyyy}{enter}`); + setDate = (dataCy, ddmmyyyy) => cy.get(`[data-cy='${dataCy}'] input`).first().clear().type(`${ddmmyyyy}{enter}`); /** - * Nach jeder Speicherung zeigt das Tool einen Erfolgs-Toast ohne Ablaufzeit. Er liegt über dem Modal - * (z-index 100001) und verdeckt dessen Knöpfe, deshalb schliesst ihn der Test über seinen Knopf. + * After each save, the tool displays a success toast with no timeout. It is positioned above the modal + * (z-index 100001) and covers its buttons, which is why the test closes it using its button. */ - schliesseToasts = () => + closeToasts = () => cy .get("body") .then(($body) => { - const knoepfe = $body.find(".p-toast-message .p-toast-icon-close"); - if (knoepfe.length) cy.wrap(knoepfe).click({ multiple: true }); + const buttons = $body.find(".p-toast-message .p-toast-icon-close"); + if (buttons.length) cy.wrap(buttons).click({ multiple: true }); }) .then(() => cy.get(".p-toast-message").should("not.exist")); - /** Dialog aus der Tabellenzelle: neuer Antritt für EINEN Studenten. */ - submitPruefungInCell = (uid, spalte, { note, datum } = {}) => { - this.schliesseToasts(); - this.getPruefungAddButton(uid, spalte).click(); + /** Dialog from the table cell: new enrollment for ONE student. */ + submitPruefungInCell = (uid, column, { note, datum } = {}) => { + this.closeToasts(); + this.getPruefungAddButton(uid, column).click(); this.getPruefungModal().should("be.visible"); - if (datum) this.setDatum("pruefung-datum", datum); + if (datum) this.setDate("pruefung-datum", datum); if (note) this.selectDropdownOption("pruefung-note", note); cy.get("[data-cy='pruefung-submit']").click(); }; - addPruefungInCell = (uid, spalte, optionen) => { - this.submitPruefungInCell(uid, spalte, optionen); + addPruefungInCell = (uid, column, options) => { + this.submitPruefungInCell(uid, column, options); waitForOk("@saveStudentPruefung"); this.getPruefungModal().should("not.be.visible"); }; - /** Bestehenden Antritt bearbeiten. Ohne `note` bleibt sie unverändert (Datumskorrektur). */ - editPruefungInCell = (uid, spalte, { note, datum } = {}) => { - this.schliesseToasts(); - this.getPruefungEditButton(uid, spalte).click(); + /** Edit an existing Antrittsdatum. If `note` is omitted, the start date remains unchanged (date correction). */ + editPruefungInCell = (uid, column, { note, datum } = {}) => { + this.closeToasts(); + this.getPruefungEditButton(uid, column).click(); this.getPruefungModal().should("be.visible"); - if (datum) this.setDatum("pruefung-datum", datum); + if (datum) this.setDate("pruefung-datum", datum); if (note) this.selectDropdownOption("pruefung-note", note); cy.get("[data-cy='pruefung-submit']").click(); @@ -231,39 +233,39 @@ class BenotungstoolPage { this.getPruefungModal().should("not.be.visible"); }; - openPruefungModalForEdit = (uid, spalte) => { - this.schliesseToasts(); - this.getPruefungEditButton(uid, spalte).click(); + openPruefungModalForEdit = (uid, column) => { + this.closeToasts(); + this.getPruefungEditButton(uid, column).click(); this.getPruefungModal().should("be.visible"); }; - /** Sammelanlage über die Toolbar abschicken, ohne auf die Antwort zu warten. */ + /** Submit the batch request via import modal without waiting for a response. */ submitPruefungBulk = ({ uids, note, punkte, datum }) => { - this.schliesseToasts(); + this.closeToasts(); cy.get("[data-cy='btn-neue-pruefung']").click(); - this.getNeuePruefungModal().should("be.visible"); + this.getNewPruefungModal().should("be.visible"); - if (datum) this.setDatum("neue-pruefung-datum", datum); + if (datum) this.setDate("neue-pruefung-datum", datum); if (note) this.selectDropdownOption("neue-pruefung-note", note); - if (punkte !== undefined) this.setNeuePruefungPunkte(punkte); + if (punkte !== undefined) this.setNewPruefungPunkte(punkte); cy.get("[data-cy='neue-pruefung-studenten']").click(); - // das Label ist "uid – Nachname Vorname – Antritte: n", daher hier bewusst per Teilstring + // The label is “uid – Last Name First Name – Appearances: n” uids.forEach((uid) => cy.contains(".p-multiselect-panel .p-multiselect-item", uid).click()); - // NICHT mit Escape: das schliesst das Bootstrap-Modal mit. Das offene Panel kann den Trigger - // verdecken, deshalb über den Schliessknopf des Panels. + // DO NOT use the Escape key: that closes the Bootstrap modal as well. The open panel may cover the trigger, + // so use the panel's close button instead. cy.get(".p-multiselect-panel .p-multiselect-close").click(); cy.get(".p-multiselect-panel").should("not.exist"); cy.get("[data-cy='neue-pruefung-submit']").click(); }; - /** Sammelanlage über die Toolbar, für mehrere Studierende auf einmal. */ - addPruefungBulk = (optionen) => { - this.submitPruefungBulk(optionen); + /** Bulk insert for multiple students. */ + addPruefungBulk = (options) => { + this.submitPruefungBulk(options); waitForOk("@createPruefungen"); - this.getNeuePruefungModal().should("not.be.visible"); + this.getNewPruefungModal().should("not.be.visible"); }; // --- Freigabe -------------------------------------------------------------------------------- @@ -278,12 +280,12 @@ class BenotungstoolPage { .find("[data-cy='freigabe-row-released']") .should("contain.text", releasedBezeichnung); - typeFreigabePasswort = (password) => cy.get("[data-cy='freigabe-passwort'] input").type(password, { log: false }); + typeFreigabePassword = (password) => cy.get("[data-cy='freigabe-passwort'] input").type(password, { log: false }); submitFreigabe = () => cy.get("[data-cy='freigabe-submit']").click(); freigeben = (password) => { - this.typeFreigabePasswort(password); + this.typeFreigabePassword(password); this.submitFreigabe(); waitForOk("@saveStudentenNoten"); this.getFreigabeModal().should("not.be.visible"); @@ -291,9 +293,9 @@ class BenotungstoolPage { // --- Import ---------------------------------------------------------------------------------- - /** rows: [[uid, note], ...] -> "uidnote" je Zeile */ + /** rows: [[uid, note], ...] -> "uidnote" per row */ submitNotenImport = (rows) => { - this.schliesseToasts(); + this.closeToasts(); cy.get("[data-cy='btn-noten-import']").click(); this.getNotenImportModal().should("be.visible"); @@ -309,7 +311,7 @@ class BenotungstoolPage { /** rows: [[uid, "dd.MM.yyyy", note], ...] */ submitPruefungImport = (rows) => { - this.schliesseToasts(); + this.closeToasts(); cy.get("[data-cy='btn-pruefung-import']").click(); this.getPruefungImportModal().should("be.visible"); @@ -350,18 +352,18 @@ class BenotungstoolPage { expectPunkte = (uid, punkte) => this.getPunkteCell(uid).should("contain.text", String(punkte)); /** Sobald ein Termin existiert, ist die Punktespalte gesperrt (editable-Guard der Spalte). */ - expectPunkteZelleGesperrt = (uid) => { + expectPunkteCellLocked = (uid) => { this.getPunkteCell(uid).click(); this.getPunkteCell(uid).find("input").should("not.exist"); }; /** Im Punktemodus ist die Vorschlagsspalte nicht editierbar - die Note kommt aus den Punkten. */ - expectNotenvorschlagGesperrt = (uid) => { + expectNotenvorschlagLocked = (uid) => { this.getCell(uid, "note_vorschlag").click(); cy.get(".tabulator-edit-list").should("not.exist"); }; - expectNoteFeldGesperrt = (dataCy) => cy.get(`[data-cy='${dataCy}']`).should("have.class", "p-disabled"); + expectNoteFieldLocked = (dataCy) => cy.get(`[data-cy='${dataCy}']`).should("have.class", "p-disabled"); /** Punktefeld im Einzeldialog; löst die Ableitung der Note aus. */ setPruefungPunkte = (punkte) => { @@ -370,7 +372,7 @@ class BenotungstoolPage { }; /** Punktefeld der Sammelanlage; dort leitet erst der Server beim Speichern ab. */ - setNeuePruefungPunkte = (punkte) => cy.get("[data-cy='neue-pruefung-punkte'] input").clear().type(String(punkte)); + setNewPruefungPunkte = (punkte) => cy.get("[data-cy='neue-pruefung-punkte'] input").clear().type(String(punkte)); // --- Hilfen ---------------------------------------------------------------------------------- @@ -383,7 +385,7 @@ class BenotungstoolPage { }; /** Date in the configured import format; kept apart from toDDMMYYYY, which the dialog uses. */ - importDatum = (isoDate, format) => (format === "yyyy-MM-dd" ? isoDate : this.toDDMMYYYY(isoDate)); + importDate = (isoDate, format) => (format === "yyyy-MM-dd" ? isoDate : this.toDDMMYYYY(isoDate)); } export const benotungstoolPage = new BenotungstoolPage(); diff --git a/tests/cypress/tasks/db.js b/tests/cypress/tasks/db.js index d97a9be01..1a5807b6b 100644 --- a/tests/cypress/tasks/db.js +++ b/tests/cypress/tasks/db.js @@ -4,16 +4,13 @@ * Connection, write guard, pooling and teardown live here. What to delete or seed belongs in a * suite-specific task file -- see notenDb.js. * - * Settings come from _DB_* and _SSH_*, where PREFIX is TEST_ENV_PREFIX. That - * indirection is the point: another suite sets its own prefix and reuses this file untouched. + * Settings come from _DB_*, where PREFIX is TEST_ENV_PREFIX. That indirection is the point: + * another suite sets its own prefix and reuses this file untouched. */ -const { ensureTunnel, closeTunnel, tunnelPort } = require("./sshTunnel"); - const PREFIX = process.env.TEST_ENV_PREFIX || "TEST"; const env = (key) => process.env[`${PREFIX}_DB_${key}`]; -const sshEnv = (key) => process.env[`${PREFIX}_SSH_${key}`]; const dbConfigured = () => Boolean(env("HOST") && env("NAME") && env("USER")); const writesAllowed = () => String(env("ALLOW_WRITES")).toLowerCase() === "true"; @@ -26,12 +23,9 @@ const getPool = () => { // lazy require so the suite loads without pg when no DB task is used const { Pool } = require("pg"); - // with a tunnel up, bind to its local end - that is what gets us past pg_hba - const forwarded = tunnelPort(); - pool = new Pool({ - host: forwarded ? "127.0.0.1" : env("HOST"), - port: forwarded || Number(env("PORT") || 5432), + host: env("HOST"), + port: Number(env("PORT") || 5432), database: env("NAME"), user: env("USER"), password: env("PASSWORD"), @@ -87,7 +81,7 @@ const explainFailure = (error) => { const host = (msg.match(/for host "([^"]+)"/) || [])[1] || "this machine"; return ( `the server refused the connection from ${host} - pg_hba.conf has no rule allowing it. ` + - `Use the SSH tunnel (${PREFIX}_SSH_TUNNEL=true) or have an entry added. Details: ${msg}` + `Run from a host the database admits, or have an entry added. Details: ${msg}` ); } if (/authentication failed/i.test(msg)) return `credentials rejected. Details: ${msg}`; @@ -97,25 +91,11 @@ const explainFailure = (error) => { return msg; }; -/** Opens the tunnel if configured, then proves the connection works. */ +/** Proves the connection works. */ const checkAvailability = async ({ requireWrites = true } = {}) => { if (!dbConfigured()) return { available: false, reason: "not-configured" }; if (requireWrites && !writesAllowed()) return { available: false, reason: "writes-disabled" }; - if (String(sshEnv("TUNNEL")).toLowerCase() === "true") { - const tunnel = await ensureTunnel({ - sshHost: sshEnv("HOST"), - sshPort: Number(sshEnv("PORT") || 22), - sshUser: sshEnv("USER"), - keyPath: sshEnv("KEY"), - passphrase: sshEnv("PASSPHRASE"), - agent: sshEnv("AGENT"), - dbHost: env("HOST"), - dbPort: Number(env("PORT") || 5432), - }); - if (!tunnel.tunnelled) return { available: false, reason: `ssh-tunnel-failed: ${tunnel.reason}` }; - } - try { await withClient((client) => client.query("SELECT 1")); return { available: true }; @@ -138,11 +118,7 @@ const closeDb = async () => { // drop the reference first: a later task must not get a pool that is ending const open = pool; pool = null; - try { - if (open) await open.end(); - } finally { - await closeTunnel(); - } + if (open) await open.end(); return null; }; diff --git a/tests/cypress/tasks/notenDb.js b/tests/cypress/tasks/notenDb.js index 152ab2916..e66c0caa0 100644 --- a/tests/cypress/tasks/notenDb.js +++ b/tests/cypress/tasks/notenDb.js @@ -1,8 +1,8 @@ /** * cy.task implementations for the Gesamtnoteneingabe fixture. Connection and guards come from - * db.js; this file only knows which rows the §1 specs write and how to undo them. + * db.js; this file only knows which rows the specs write and how to undo them. * - * Needed because the Noten API has no delete endpoint, while every §1 test writes + * Needed because the Noten API has no delete endpoint, while every test writes * campus.tbl_lvgesamtnote and lehre.tbl_pruefung. Without a reset the suite is single-shot. * * Every statement is scoped by an explicit student_uid list plus the test LV and semester. @@ -136,11 +136,11 @@ const seedPruefung = async (scope) => { assertWritable(); assertScope({ ...scope, studentUids: [scope.studentUid] }); - const { lvId, semKurzbz, studentUid, lehreinheitId, note, datum, typ, mitarbeiterUid } = scope; + const { lvId, semKurzbz, studentUid, lehreinheitId, note, datum, type, mitarbeiterUid } = scope; if (note === undefined || note === null) throw new Error("noten:db seedPruefung - note is required"); if (!datum) throw new Error("noten:db seedPruefung - datum is required"); - if (!typ) throw new Error("noten:db seedPruefung - typ is required"); + if (!type) throw new Error("noten:db seedPruefung - type is required"); if (!lehreinheitId) throw new Error("noten:db seedPruefung - lehreinheitId is required"); if (!mitarbeiterUid) throw new Error("noten:db seedPruefung - mitarbeiterUid is required"); @@ -163,10 +163,10 @@ const seedPruefung = async (scope) => { anmerkung, insertamum, insertvon) VALUES ($1, $2, $3, $4, $5, $6, '', NOW(), $3) RETURNING pruefung_id`, - [lehreinheitId, studentUid, mitarbeiterUid, note, typ, datum], + [lehreinheitId, studentUid, mitarbeiterUid, note, type, datum], ); - return { pruefungId: res.rows[0].pruefung_id, note, datum, typ }; + return { pruefungId: res.rows[0].pruefung_id, note, datum, type }; }); }; @@ -225,7 +225,7 @@ const assertReadable = () => { const readRows = async (sql, params) => withClient(async (client) => (await client.query(sql, params)).rows); /** Same query as Lehrveranstaltung_model::getLeIdsByStudent. Empty = no participant. */ -const lehreinheitenDesStudenten = async (scope) => { +const lehreinheitenOfStudent = async (scope) => { assertReadable(); assertScope({ ...scope, studentUids: [scope.studentUid] }); @@ -239,7 +239,7 @@ const lehreinheitenDesStudenten = async (scope) => { }; /** Same joins as Lehreinheit_model::getLehreinheitenForLv. */ -const lehreinheitenDerLv = async (scope) => { +const lehreinheitenOfLv = async (scope) => { assertReadable(); assertScope(scope, LV_SCOPE); @@ -255,7 +255,7 @@ const lehreinheitenDerLv = async (scope) => { }; /** A Lehreinheit of another LV in the same semester, or null. */ -const fremdeLehreinheit = async (scope) => { +const foreignLehreinheit = async (scope) => { assertReadable(); assertScope(scope, LV_SCOPE); @@ -269,7 +269,7 @@ const fremdeLehreinheit = async (scope) => { }; /** An active student without any Lehreinheit in the LV, or null. */ -const nichtTeilnehmer = async (scope) => { +const nonParticipant = async (scope) => { assertReadable(); assertScope(scope, LV_SCOPE); @@ -286,23 +286,23 @@ const nichtTeilnehmer = async (scope) => { }; /** LV notes of the student in OTHER LVs of the semester; resetStudents never reaches them. */ -const andereLvNoten = async (scope) => { +const otherLvNoten = async (scope) => { assertReadable(); assertScope({ ...scope, studentUids: [scope.studentUid] }); const rows = await readRows( - `SELECT count(*)::int AS anzahl FROM campus.tbl_lvgesamtnote + `SELECT count(*)::int AS total FROM campus.tbl_lvgesamtnote WHERE student_uid = $1 AND studiensemester_kurzbz = $2 AND lehrveranstaltung_id <> $3`, [scope.studentUid, scope.semKurzbz, scope.lvId], ); - return rows[0].anzahl; + return rows[0].total; }; -const pruefungstypVorhanden = async ({ typ } = {}) => { +const pruefungstypExists = async ({ type } = {}) => { assertReadable(); - if (!typ) throw new Error("noten:db pruefungstypVorhanden - typ is required"); + if (!type) throw new Error("noten:db pruefungstypExists - type is required"); - const rows = await readRows("SELECT 1 FROM lehre.tbl_pruefungstyp WHERE pruefungstyp_kurzbz = $1", [typ]); + const rows = await readRows("SELECT 1 FROM lehre.tbl_pruefungstyp WHERE pruefungstyp_kurzbz = $1", [type]); return rows.length > 0; }; @@ -335,9 +335,9 @@ const vorlagen = async ({ kurzbz } = {}) => { }; /** Lektoren of a Lehreinheit, sorted by uid like Noten::lehrendeDerLehreinheit (strcmp). */ -const lehrendeDerLehreinheit = async ({ lehreinheitId } = {}) => { +const lehrendeOfLehreinheit = async ({ lehreinheitId } = {}) => { assertReadable(); - if (!lehreinheitId) throw new Error("noten:db lehrendeDerLehreinheit - lehreinheitId is required"); + if (!lehreinheitId) throw new Error("noten:db lehrendeOfLehreinheit - lehreinheitId is required"); const rows = await readRows( `SELECT mitarbeiter_uid FROM lehre.tbl_lehreinheitmitarbeiter @@ -368,15 +368,15 @@ const registerNotenDbTasks = (on) => { "noten:db:seedPruefung": (scope) => seedPruefung(scope), "noten:db:seedZeugnisnote": (scope) => seedZeugnisnote(scope), "noten:db:readLvGesamtnote": (scope) => readLvGesamtnote(scope), - "noten:db:lehreinheitenDesStudenten": (scope) => lehreinheitenDesStudenten(scope), - "noten:db:lehreinheitenDerLv": (scope) => lehreinheitenDerLv(scope), - "noten:db:fremdeLehreinheit": (scope) => fremdeLehreinheit(scope), - "noten:db:nichtTeilnehmer": (scope) => nichtTeilnehmer(scope), - "noten:db:andereLvNoten": (scope) => andereLvNoten(scope), - "noten:db:pruefungstypVorhanden": (scope) => pruefungstypVorhanden(scope), + "noten:db:lehreinheitenOfStudent": (scope) => lehreinheitenOfStudent(scope), + "noten:db:lehreinheitenOfLv": (scope) => lehreinheitenOfLv(scope), + "noten:db:foreignLehreinheit": (scope) => foreignLehreinheit(scope), + "noten:db:nonParticipant": (scope) => nonParticipant(scope), + "noten:db:otherLvNoten": (scope) => otherLvNoten(scope), + "noten:db:pruefungstypExists": (scope) => pruefungstypExists(scope), "noten:db:deletePruefung": (scope) => deletePruefung(scope), "noten:db:vorlagen": (scope) => vorlagen(scope), - "noten:db:lehrendeDerLehreinheit": (scope) => lehrendeDerLehreinheit(scope), + "noten:db:lehrendeOfLehreinheit": (scope) => lehrendeOfLehreinheit(scope), "noten:db:pruefungMitarbeiter": (scope) => pruefungMitarbeiter(scope), "noten:db:close": () => closeDb(), }); diff --git a/tests/cypress/tasks/notenProfil.js b/tests/cypress/tasks/notenProfil.js deleted file mode 100644 index 9b53f229c..000000000 --- a/tests/cypress/tasks/notenProfil.js +++ /dev/null @@ -1,344 +0,0 @@ -/** - * Konfigurationsprofile der Benotungstool-Suite auf der Testinstanz. - * - * Ein Profil ändert application/config/noten.php und die define()-Schalter in - * config/global.config.inc.php. Die Originale liegen im Home-Verzeichnis des SSH-Benutzers, also - * ausserhalb des Webroots: global.config.inc.php enthält Zugangsdaten. - * - * zustand.json hält die md5 jeder geschriebenen Datei. Weicht eine Datei davon ab, ist sie seither neu - * ausgerollt. Dann gilt sie als Original, und die alte Sicherung verfällt. - */ - -const crypto = require("crypto"); -const path = require("path"); -const { resolveAuth } = require("./sshTunnel"); -const { basis, profile } = require("../profiles/noten"); - -const PREFIX = process.env.TEST_ENV_PREFIX || "TEST"; - -// Die Instanz kann auf einem anderen Host liegen als der SSH-Tunnel zur Datenbank. Ist -// _PROFIL_SSH_HOST gesetzt, kommt jeder SSH-Wert aus _PROFIL_SSH_*; ohne Schlüssel gilt -// dann der SSH-Agent. -const envWert = (key) => { - const eigenerHost = key.startsWith("SSH_") && Boolean(process.env[`${PREFIX}_PROFIL_SSH_HOST`]); - return process.env[`${PREFIX}_${eigenerHost ? "PROFIL_" : ""}${key}`]; -}; - -const DATEIEN = { - config: "application/config/noten.php", - flags: "config/global.config.inc.php", -}; - -// relativ zum Home-Verzeichnis, dort beginnt jede SFTP-Sitzung -const SICHERUNG = ".cypress-noten-profil"; -const ZUSTAND = `${SICHERUNG}/zustand.json`; - -const NAME = /^[a-z0-9-]+$/; -const SCHLUESSEL = /^[A-Z0-9_]+$/; - -const md5 = (inhalt) => crypto.createHash("md5").update(inhalt).digest("hex"); - -const sftpAufruf = (fn) => new Promise((resolve, reject) => fn((err, wert) => (err ? reject(err) : resolve(wert)))); - -const sshKonfiguriert = () => Boolean(envWert("SSH_HOST") && envWert("SSH_USER")); - -// --- SFTP --- - -const verbinde = () => - new Promise((resolve, reject) => { - const { Client } = require("ssh2"); - - let auth; - try { - auth = resolveAuth({ - keyPath: envWert("SSH_KEY"), - passphrase: envWert("SSH_PASSPHRASE"), - agent: envWert("SSH_AGENT"), - }); - } catch (error) { - reject(error); - return; - } - - const client = new Client(); - client - .on("ready", () => client.sftp((err, sftp) => (err ? reject(err) : resolve({ client, sftp })))) - .on("error", (err) => - reject(new Error(`SSH zu ${envWert("SSH_USER")}@${envWert("SSH_HOST")} gescheitert: ${err.message}`)), - ) - .connect({ - host: envWert("SSH_HOST"), - port: Number(envWert("SSH_PORT") || 22), - username: envWert("SSH_USER"), - privateKey: auth.privateKey, - passphrase: auth.passphrase, - agent: auth.agent, - readyTimeout: 15000, - }); - }); - -const mitSftp = async (fn) => { - const { client, sftp } = await verbinde(); - try { - return await fn(sftp); - } finally { - client.end(); - } -}; - -const existiert = (sftp, datei) => new Promise((resolve) => sftp.stat(datei, (err) => resolve(!err))); -const lies = (sftp, datei) => sftpAufruf((cb) => sftp.readFile(datei, cb)); -const schreibeRoh = (sftp, datei, inhalt, mode) => sftpAufruf((cb) => sftp.writeFile(datei, inhalt, { mode }, cb)); - -/** Schreibt über eine Temporärdatei, damit kein Request eine halbe Konfiguration liest. */ -const schreibe = async (sftp, datei, inhalt) => { - // .php: ein Webserver führt die Datei aus, statt ihre Zugangsdaten auszuliefern - const temp = `${datei}.cypress-temp.php`; - await schreibeRoh(sftp, temp, inhalt, 0o644); - await sftpAufruf((cb) => sftp.ext_openssh_rename(temp, datei, cb)); -}; - -const sicherungVon = (entfernt) => `${SICHERUNG}/${path.posix.basename(entfernt)}`; - -const leseZustand = async (sftp) => - (await existiert(sftp, ZUSTAND)) ? JSON.parse((await lies(sftp, ZUSTAND)).toString("utf8")) : null; - -const schreibeZustand = (sftp, zustand) => schreibeRoh(sftp, ZUSTAND, JSON.stringify(zustand, null, 2), 0o600); - -// --- Umbau der Dateien --- - -const phpString = (text) => `'${String(text).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; - -const phpWert = (wert) => { - if (wert === null) return "null"; - if (typeof wert === "boolean" || typeof wert === "number") return String(wert); - if (typeof wert === "string") return phpString(wert); - throw new Error(`Ein define()-Schalter nimmt nur einen einfachen Wert, nicht ${JSON.stringify(wert)}.`); -}; - -// nur ASCII: der Block ändert die Kodierung der Datei nicht -const asciiJson = (wert) => - JSON.stringify(wert).replace(/[-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`); - -/** Hängt die Schlüssel des Profils an noten.php. Die spätere Zuweisung gewinnt. */ -const mitConfig = (original, name, config) => { - const text = original.toString("latin1"); - const block = - `\n// cypress-profil "${name}": nur auf der Testinstanz, die Suite stellt das Original wieder her\n` + - `$config = array_replace($config, json_decode(${phpString(asciiJson(config))}, true));\n`; - const ende = text.search(/\?>\s*$/); - - return Buffer.from(ende === -1 ? text + block : text.slice(0, ende) + block + text.slice(ende), "latin1"); -}; - -/** Ersetzt die define()-Zeile jedes Schalters. Ein zweites define() druckt eine Notice in die Seite. */ -const mitFlags = (original, flags) => { - let text = original.toString("latin1"); - - Object.entries(flags).forEach(([flag, wert]) => { - const muster = new RegExp(`^([ \\t]*)define\\(\\s*['"]${flag}['"]\\s*,[^;]*;`, "m"); - if (!muster.test(text)) { - throw new Error( - `${DATEIEN.flags} definiert ${flag} nicht. Ein Profil ändert nur einen bestehenden Schalter.`, - ); - } - text = text.replace(muster, (treffer, einzug) => `${einzug}define('${flag}', ${phpWert(wert)});`); - }); - - return Buffer.from(text, "latin1"); -}; - -// --- Profile --- - -/** Profil samt Basis. pruefe nennt Werte aus getCisConfig, die ein Flag nur mittelbar ändert. */ -const profilDaten = (name) => { - if (!NAME.test(String(name)) || !Object.prototype.hasOwnProperty.call(profile, name)) { - throw new Error(`Unbekanntes Profil "${name}". Vorhanden: ${Object.keys(profile).join(", ")}`); - } - - const daten = { - config: { ...basis.config, ...profile[name].config }, - flags: { ...basis.flags, ...profile[name].flags }, - pruefe: { ...profile[name].pruefe }, - }; - - [...Object.keys(daten.config), ...Object.keys(daten.flags)].forEach((key) => { - if (!SCHLUESSEL.test(key)) throw new Error(`Ungültiger Schlüssel "${key}" in Profil "${name}".`); - }); - - return daten; -}; - -/** Stellt eine Datei her, die ein Profil geschrieben hat. Eine neu ausgerollte Datei bleibt stehen. */ -const stelleHer = async (sftp, zustand, schluessel) => { - const eintrag = zustand.dateien[schluessel]; - if (!eintrag) return; - - const entfernt = `${zustand.root}/${DATEIEN[schluessel]}`; - const sicherung = sicherungVon(entfernt); - - if (await existiert(sftp, sicherung)) { - if (md5(await lies(sftp, entfernt)) === eintrag.md5) { - await schreibe(sftp, entfernt, await lies(sftp, sicherung)); - } else { - console.warn(`[profil] ${entfernt} ist seit dem Profil neu ausgerollt und bleibt stehen.`); - } - await sftpAufruf((cb) => sftp.unlink(sicherung, cb)); - } - - delete zustand.dateien[schluessel]; - await schreibeZustand(sftp, zustand); -}; - -/** Schreibt eine Datei nach dem Profil. Die Grundlage ist immer das Original, nie ein anderes Profil. */ -const setze = async (sftp, zustand, schluessel, umbau) => { - const entfernt = `${zustand.root}/${DATEIEN[schluessel]}`; - const sicherung = sicherungVon(entfernt); - const aktuell = await lies(sftp, entfernt); - const eintrag = zustand.dateien[schluessel]; - const vomProfil = Boolean(eintrag) && md5(aktuell) === eintrag.md5; - - let original = aktuell; - if (vomProfil) { - if (!(await existiert(sftp, sicherung))) { - throw new Error(`Die Sicherung ${sicherung} fehlt. Das Original von ${entfernt} ist unbekannt.`); - } - original = await lies(sftp, sicherung); - } else { - if (eintrag) - console.warn(`[profil] ${entfernt} ist seit dem letzten Profil neu ausgerollt und gilt als Original.`); - await schreibeRoh(sftp, sicherung, aktuell, 0o600); - } - - const neu = umbau(original); - - // zuerst der Zustand: bricht das Schreiben ab, gilt die Datei beim nächsten Lauf als Original - zustand.dateien[schluessel] = { md5: md5(neu) }; - await schreibeZustand(sftp, zustand); - await schreibe(sftp, entfernt, neu); -}; - -const gleich = (ist, soll) => - typeof soll === "boolean" ? Boolean(ist) === soll : JSON.stringify(ist) === JSON.stringify(soll); - -/** Die Daten von getCisConfig, oder null mit Grund bei einem vorübergehenden Fehler (Netz, HTTP 5xx). */ -const leseCisConfig = async (url, auth) => { - let antwort; - try { - antwort = await fetch(url, { headers: { Authorization: auth } }); - } catch (error) { - return { ist: null, grund: error.message }; - } - - const text = await antwort.text(); - try { - const ist = JSON.parse(text).data; - if (ist) return { ist }; - } catch (e) { - // keine JSON-Antwort - } - - // unter 500 steht eine PHP-Meldung vor dem JSON: die Datei ist kaputt, Warten hilft nicht - if (antwort.status < 500) { - throw new Error( - `getCisConfig antwortet nach dem Profilwechsel mit HTTP ${antwort.status}: ${text.slice(0, 300)}`, - ); - } - return { ist: null, grund: `getCisConfig antwortet mit HTTP ${antwort.status}` }; -}; - -/** Wartet, bis getCisConfig das Profil liefert. PHP-FPM liest eine geänderte Datei erst nach opcache.revalidate_freq. */ -const pruefeInstanz = async (daten) => { - const erwartet = { ...daten.config, ...daten.flags, ...daten.pruefe }; - const basisUrl = String(process.env.BASE_URL).replace(/\/+$/, ""); - const url = `${basisUrl}/index.ci.php/api/frontend/v1/Noten/getCisConfig`; - const auth = `Basic ${Buffer.from(`${process.env.USER_NAME}:${process.env.USER_PASSWORD}`).toString("base64")}`; - const ende = Date.now() + 60000; - - for (;;) { - const { ist, grund } = await leseCisConfig(url, auth); - let offen = grund; - - if (ist) { - const pruefbar = Object.keys(erwartet).filter((key) => key in ist); - if (!pruefbar.length) { - console.warn("[profil] getCisConfig liefert keinen Schlüssel des Profils. Der Wechsel ist ungeprüft."); - return; - } - - const abweichend = pruefbar.filter((key) => !gleich(ist[key], erwartet[key])); - if (!abweichend.length) return; - - const liste = abweichend.map( - (k) => ` ${k}: ${JSON.stringify(ist[k])} statt ${JSON.stringify(erwartet[k])}`, - ); - offen = `die Instanz zeigt das Profil nicht:\n${liste.join("\n")}`; - } - - if (Date.now() > ende) throw new Error(`Profilwechsel nach 60 Sekunden ungeprüft, ${offen}`); - await new Promise((resolve) => setTimeout(resolve, 2000)); - } -}; - -// --- öffentlich --- - -/** Nimmt das aktive Profil zurück. Liefert seinen Namen oder null. */ -const wiederherstellen = async () => { - if (!sshKonfiguriert()) return null; - - return mitSftp(async (sftp) => { - const zustand = await leseZustand(sftp); - if (!zustand || !Object.keys(zustand.dateien).length) return null; - - const war = zustand.profil; - for (const schluessel of Object.keys(DATEIEN)) await stelleHer(sftp, zustand, schluessel); - - zustand.profil = null; - await schreibeZustand(sftp, zustand); - console.log(`[profil] "${war}" ist zurückgenommen.`); - return war; - }); -}; - -/** Schaltet die Instanz auf ein Profil. Ohne Namen stellt es die Originale her. */ -const anwenden = async (name) => { - if (!name) return wiederherstellen(); - - const daten = profilDaten(name); - const root = envWert("REMOTE_ROOT"); - if (!sshKonfiguriert() || !root) { - throw new Error( - `Profil "${name}" braucht ${PREFIX}_SSH_HOST, ${PREFIX}_SSH_USER und ${PREFIX}_REMOTE_ROOT in tests/cypress/.env.`, - ); - } - - await mitSftp(async (sftp) => { - if (!(await existiert(sftp, SICHERUNG))) await sftpAufruf((cb) => sftp.mkdir(SICHERUNG, { mode: 0o700 }, cb)); - - const zustand = (await leseZustand(sftp)) || { dateien: {} }; - if (zustand.root && zustand.root !== root && Object.keys(zustand.dateien).length) { - throw new Error( - `Auf ${zustand.root} ist noch "${zustand.profil}" aktiv. Zuerst: npm run noten:profil -- wiederherstellen`, - ); - } - zustand.root = root; - zustand.profil = name; - - if (Object.keys(daten.config).length) - await setze(sftp, zustand, "config", (o) => mitConfig(o, name, daten.config)); - else await stelleHer(sftp, zustand, "config"); - - if (Object.keys(daten.flags).length) await setze(sftp, zustand, "flags", (o) => mitFlags(o, daten.flags)); - else await stelleHer(sftp, zustand, "flags"); - - await schreibeZustand(sftp, zustand); - }); - - await pruefeInstanz(daten); - console.log(`[profil] "${name}" ist aktiv.`); - return name; -}; - -const status = () => (sshKonfiguriert() ? mitSftp(leseZustand) : Promise.resolve(null)); - -module.exports = { anwenden, wiederherstellen, status, profilDaten }; diff --git a/tests/cypress/tools/checks/noten.js b/tests/cypress/tools/checks/noten.js index 944910024..70ac9639a 100644 --- a/tests/cypress/tools/checks/noten.js +++ b/tests/cypress/tools/checks/noten.js @@ -102,7 +102,7 @@ module.exports = { sql: `SELECT COALESCE((SELECT note::text FROM lehre.tbl_note WHERE bezeichnung = 'Noch nicht eingetragen'), '') AS value`, ok: (r) => r.value !== "", - hint: "Missing from the base dump. Rebuild with system/setup_testinstance.php --setup.", + hint: "Missing from the base dump. Rebuild the database with db_setup.sh.", }, { label: "Pruefungstypen Termin1/Termin2/Termin3/kommPruef", diff --git a/tests/cypress/tools/dbCheck.js b/tests/cypress/tools/dbCheck.js index 1d80cd34f..7dd781393 100644 --- a/tests/cypress/tools/dbCheck.js +++ b/tests/cypress/tools/dbCheck.js @@ -6,8 +6,8 @@ * node tests/cypress/tools/dbCheck.js noten seed apply the suite's seeder, then re-check * node tests/cypress/tools/dbCheck.js noten env print the .env block to use * - * A suite contributes tools/checks/.js exporting { title, checks, sqlFiles }. Connection, - * tunnel and output are shared and live here. A sqlFiles entry is a path or { file, group }; a + * A suite contributes tools/checks/.js exporting { title, checks, sqlFiles }. Connection + * and output are shared and live here. A sqlFiles entry is a path or { file, group }; a * "-- ==== ====" line starts a group. * * `check` is read-only; `seed` needs _DB_ALLOW_WRITES=true and an owner role. @@ -17,7 +17,7 @@ const fs = require("fs"); const path = require("path"); try { - require("dotenv").config({ path: path.join(__dirname, "..", ".env") }); + require("dotenv").config({ path: path.join(__dirname, "..", "suites", ".env") }); } catch (e) { // dotenv missing -> fall back to the ambient environment } @@ -116,7 +116,7 @@ const applySqlFile = async (client, entry) => { if (/permission denied/i.test(error.message)) { console.error(`${error.message}\n`); console.error(`Seeding needs the database owner, but ${db.PREFIX}_DB_USER is "${db.env("USER")}".`); - console.error("Point it at the owner role, or seed via system/setup_testinstance.php.\n"); + console.error("Point it at the owner role, or rebuild the database with db_setup.sh.\n"); return 2; } throw error; @@ -137,7 +137,7 @@ const applySqlFile = async (client, entry) => { const printEnv = () => { const p = db.PREFIX; console.log(` -Add to tests/cypress/.env (gitignored): +Add to tests/cypress/suites/.env (gitignored): TEST_ENV_PREFIX=${p} ${p}_DB_HOST= @@ -146,13 +146,6 @@ Add to tests/cypress/.env (gitignored): ${p}_DB_USER= ${p}_DB_PASSWORD= ${p}_DB_ALLOW_WRITES=true - -If pg_hba.conf does not admit this machine, add the tunnel: - - ${p}_SSH_TUNNEL=true - ${p}_SSH_HOST= - ${p}_SSH_USER= - ${p}_SSH_KEY= `); }; @@ -181,7 +174,6 @@ const main = async () => { return 2; } - // Opens the SSH tunnel when configured, so a workstation run needs no manual forward. const status = await db.checkAvailability({ requireWrites: mode !== "check" }); if (!status.available) { console.error(`\nCould not connect: ${status.reason}\n`);