, '', '') is created by group studiengang_5.
+INSERT INTO public.tbl_studentlehrverband
+ (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe)
+SELECT s.uid, sem.kurzbz, 5, lv.semester, '', ''
+ FROM stud s, sem, lv
+ WHERE lv.main
+ ON CONFLICT DO NOTHING;
+
+-- ------------------------------------------------------------------- Lehreinheiten and Lektoren
+INSERT INTO lehre.tbl_lehreinheit
+ (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrform_kurzbz,
+ wochenrythmus, raumtyp, raumtypalternativ, sprache, lehrfach_id)
+SELECT lv.le_id, lv.lv_id, sem.kurzbz, 'VO', 1, 'Dummy', 'Dummy', 'German', lv.lv_id
+ FROM lv, sem
+ ON CONFLICT DO NOTHING;
+
+-- Fix the semester if the Lehreinheit is left over from a run in a different one.
+UPDATE lehre.tbl_lehreinheit le
+ SET studiensemester_kurzbz = sem.kurzbz
+ FROM lv, sem
+ WHERE le.lehreinheit_id = lv.le_id
+ AND le.studiensemester_kurzbz IS DISTINCT FROM sem.kurzbz;
+
+INSERT INTO lehre.tbl_lehreinheitmitarbeiter (lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz)
+SELECT le_id, lektor, 'Lektor'
+ FROM lv
+ ON CONFLICT DO NOTHING;
+
+-- --------------------------------------------------------------------------------------- groups
+INSERT INTO public.tbl_gruppe (gruppe_kurzbz, studiengang_kz, semester, bezeichnung, aktiv)
+SELECT grp, 5, semester, grp_text, true
+ FROM lv
+ ON CONFLICT DO NOTHING;
+
+-- The row the existing seeders lack: attach the group to the Lehreinheit with gruppe_kurzbz set.
+-- The primary key is a serial, so NOT EXISTS instead of ON CONFLICT.
+INSERT INTO lehre.tbl_lehreinheitgruppe (lehreinheit_id, studiengang_kz, semester, gruppe_kurzbz)
+SELECT lv.le_id, 5, lv.semester, lv.grp
+ FROM lv
+ WHERE NOT EXISTS (SELECT 1 FROM lehre.tbl_lehreinheitgruppe g
+ WHERE g.lehreinheit_id = lv.le_id AND g.gruppe_kurzbz = lv.grp);
+
+-- Membership WITH a semester. The primary key is (uid, gruppe_kurzbz) and excludes the semester,
+-- so DO UPDATE repairs a row from an earlier run instead of duplicating it.
+INSERT INTO public.tbl_benutzergruppe (uid, gruppe_kurzbz, studiensemester_kurzbz)
+SELECT s.uid, lv.grp, sem.kurzbz
+ FROM stud s, lv, sem
+ WHERE s.person_id <= lv.members_up_to
+ ON CONFLICT (uid, gruppe_kurzbz) DO UPDATE
+ SET studiensemester_kurzbz = EXCLUDED.studiensemester_kurzbz;
+
+-- -------------------------------------------------------------------------------- Notenschluessel
+INSERT INTO lehre.tbl_notenschluessel (notenschluessel_kurzbz, bezeichnung)
+VALUES ('CYNOTEN', 'Cypress Testnotenschluessel')
+ ON CONFLICT DO NOTHING;
+
+-- getNote() takes the row with the largest punkte value <= the points given. The 0 threshold makes
+-- sure every value maps to a grade. Serial primary key, so NOT EXISTS.
+INSERT INTO lehre.tbl_notenschluesselaufteilung (notenschluessel_kurzbz, note, punkte)
+SELECT 'CYNOTEN', a.note, a.punkte
+ FROM (VALUES (5, 0.0), (4, 51.0), (3, 64.0), (2, 77.0), (1, 90.0)) AS a(note, punkte)
+ WHERE NOT EXISTS (SELECT 1 FROM lehre.tbl_notenschluesselaufteilung x
+ WHERE x.notenschluessel_kurzbz = 'CYNOTEN' AND x.punkte = a.punkte);
+
+-- Set the semester explicitly: getKurzbzForLv() builds "... AND studiensemester_kurzbz = ? OR
+-- studiensemester_kurzbz IS NULL" without parentheses, so a NULL-semester assignment would apply
+-- to every Lehrveranstaltung through AND/OR precedence.
+INSERT INTO lehre.tbl_notenschluesselzuordnung
+ (notenschluessel_kurzbz, lehrveranstaltung_id, studiensemester_kurzbz)
+SELECT 'CYNOTEN', lv.lv_id, sem.kurzbz
+ FROM lv, sem
+ WHERE lv.main
+ AND NOT EXISTS (SELECT 1 FROM lehre.tbl_notenschluesselzuordnung z
+ WHERE z.notenschluessel_kurzbz = 'CYNOTEN'
+ AND z.lehrveranstaltung_id = lv.lv_id
+ AND z.studiensemester_kurzbz = sem.kurzbz);
+
+COMMIT;
+
+-- Fires if the view stays empty anyway, which is the bug this seeder exists to fix.
+DO $$
+DECLARE v_count integer;
+BEGIN
+ SELECT count(*) INTO v_count
+ FROM campus.vw_student_lehrveranstaltung v, sem
+ WHERE v.lehrveranstaltung_id = 5221 AND v.studiensemester_kurzbz = sem.kurzbz;
+
+ IF v_count = 0 THEN
+ RAISE EXCEPTION 'No students in LV 5221 (%), fixture unusable.', (SELECT kurzbz FROM sem);
+ END IF;
+
+ RAISE NOTICE 'OK: % students in LV 5221 (%)', v_count, (SELECT kurzbz FROM sem);
+END $$;
+
+SELECT (SELECT kurzbz FROM sem) AS semester,
+ (SELECT count(*) FROM campus.vw_student_lehrveranstaltung v, sem
+ WHERE v.lehrveranstaltung_id = 5221 AND v.studiensemester_kurzbz = sem.kurzbz) AS lv5221,
+ (SELECT count(*) FROM campus.vw_student_lehrveranstaltung v, sem
+ WHERE v.lehrveranstaltung_id = 5121 AND v.studiensemester_kurzbz = sem.kurzbz) AS lv5121,
+ (SELECT note FROM lehre.tbl_note WHERE bezeichnung = 'entschuldigt') AS entschuldigt,
+ (SELECT note FROM lehre.tbl_note WHERE bezeichnung = 'Noch nicht eingetragen') AS noch_nicht,
+ (SELECT count(*) FROM lehre.tbl_notenschluesselaufteilung
+ WHERE notenschluessel_kurzbz = 'CYNOTEN') AS notenschluessel;
+
+-- ==== schema_grants ====
+-- Vollzugriff auf ALLE Anwendungsschemata für alle DB-Rollen.
+
+DO $$
+DECLARE
+ v_grantee text;
+ v_target text;
+ v_schema text;
+ v_grantees text[] := ARRAY['web', 'vilesci', 'wawi', 'admin', 'PUBLIC'];
+ v_done integer := 0;
+BEGIN
+ FOREACH v_grantee IN ARRAY v_grantees
+ LOOP
+ IF v_grantee = 'PUBLIC' THEN
+ v_target := 'PUBLIC';
+ ELSE
+ CONTINUE WHEN NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = v_grantee);
+ v_target := quote_ident(v_grantee);
+ END IF;
+
+ FOR v_schema IN
+ SELECT nspname
+ FROM pg_namespace
+ WHERE nspname NOT LIKE 'pg\_%' AND nspname <> 'information_schema'
+ ORDER BY nspname
+ LOOP
+ EXECUTE format('GRANT ALL ON SCHEMA %I TO %s', v_schema, v_target);
+ EXECUTE format('GRANT ALL ON ALL TABLES IN SCHEMA %I TO %s', v_schema, v_target);
+ EXECUTE format('GRANT ALL ON ALL SEQUENCES IN SCHEMA %I TO %s', v_schema, v_target);
+ EXECUTE format('GRANT ALL ON ALL FUNCTIONS IN SCHEMA %I TO %s', v_schema, v_target);
+
+ EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO %s', v_schema, v_target);
+ EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO %s', v_schema, v_target);
+ EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON FUNCTIONS TO %s', v_schema, v_target);
+
+ v_done := v_done + 1;
+ END LOOP;
+ END LOOP;
+
+ RAISE NOTICE 'Grants auf % Schema/Rollen-Kombinationen vergeben.', v_done;
+END $$;
+
+-- ==== benotungstool_berechtigungen ====
+-- Benotungstool permissions for the demo accounts. Without them every endpoint answers 403.
+--
+-- demolektor1 teacher; no oe_kurzbz - assertLvAccess scopes him by the Lehreinheiten he teaches
+-- demoassistenz programme assistant for stg3 and stg5 (test courses 5221 and 5121 live in stg5)
+--
+-- Idempotent.
+
+BEGIN;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM public.tbl_benutzer WHERE uid = 'demolektor1') THEN
+ RAISE EXCEPTION 'Benutzer demolektor1 fehlt, die Basisdaten sind unvollstaendig.';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM public.tbl_benutzer WHERE uid = 'demoassistenz') THEN
+ RAISE EXCEPTION 'Benutzer demoassistenz fehlt, die Basisdaten sind unvollstaendig.';
+ END IF;
+END $$;
+
+-- Der Lektor: darf das Werkzeug öffnen, sieht aber nur seine eigenen Lehrveranstaltungen.
+INSERT INTO system.tbl_benutzerrolle (uid, berechtigung_kurzbz, art, oe_kurzbz, insertvon, insertamum)
+SELECT 'demolektor1', 'lehre/benotungstool', 'suid', NULL, 'seeder', now()
+ WHERE NOT EXISTS (
+ SELECT 1 FROM system.tbl_benutzerrolle
+ WHERE uid = 'demolektor1'
+ AND berechtigung_kurzbz = 'lehre/benotungstool'
+ AND oe_kurzbz IS NULL
+ );
+
+-- Die Assistenz: darf die Lehrveranstaltungen ihrer Studiengänge benoten.
+INSERT INTO system.tbl_benutzerrolle (uid, berechtigung_kurzbz, art, oe_kurzbz, insertvon, insertamum)
+SELECT 'demoassistenz', 'lehre/benotungstool_assistenz', 'suid', oe.oe_kurzbz, 'seeder', now()
+ FROM (VALUES ('stg3'), ('stg5')) AS oe(oe_kurzbz)
+ WHERE NOT EXISTS (
+ SELECT 1 FROM system.tbl_benutzerrolle b
+ WHERE b.uid = 'demoassistenz'
+ AND b.berechtigung_kurzbz = 'lehre/benotungstool_assistenz'
+ AND b.oe_kurzbz = oe.oe_kurzbz
+ );
+
+COMMIT;
+
+-- Zurücknehmen:
+-- DELETE FROM system.tbl_benutzerrolle
+-- WHERE insertvon = 'seeder' AND berechtigung_kurzbz LIKE 'lehre/benotungstool%';
+
+-- ==== benotungstool_fixture_erweitert ====
+-- Extensions of the Benotungstool fixture. Apply after group benotungstool_noten.
+--
+-- LE 51103 a second Lehreinheit of LV 5221 with two Lektoren and two students of its own. The write
+-- paths replace a foreign Lehreinheit, and the grader rules need a Lehreinheit with more
+-- than one Lektor. demoadmin sorts before demolektor1, so "the caller" and "the first
+-- Lektor" give different results.
+-- LE 51104 demolektor1 in LV 5221 in the last Sommersemester with a passed grade entry deadline.
+-- The deadline tests need a semester in which the user teaches.
+-- Vorlagen demo texts for 'Notenfreigabe' and 'Sancho_Mail_Template'. Without them every release
+-- mail has an empty body. The dbupdate script creates only the tbl_vorlage row.
+--
+-- The new students sort after 'Student 1'..'Student 12', so the first twelve students of the suite
+-- stay the same. Idempotent.
+
+BEGIN;
+
+-- group benotungstool_noten creates temp tables with the same names when both groups run in one session
+DROP TABLE IF EXISTS pg_temp.sem, pg_temp.sem_frist, pg_temp.lv, pg_temp.stud;
+
+-- Test semester: the running one, else the last started. Same rule as the suite and group benotungstool_noten.
+CREATE TEMP TABLE sem AS
+SELECT studiensemester_kurzbz AS kurzbz
+ FROM public.tbl_studiensemester
+ WHERE start <= now()
+ ORDER BY (ende >= now()) DESC, start DESC
+ LIMIT 1;
+
+-- The last Sommersemester after its deadline. NOTENEINTRAGUNGSFRIST_SS defaults to 15 November.
+CREATE TEMP TABLE sem_frist AS
+SELECT studiensemester_kurzbz AS kurzbz
+ FROM public.tbl_studiensemester
+ WHERE studiensemester_kurzbz ~ '^SS[0-9]{4}$'
+ AND make_date(substring(studiensemester_kurzbz FROM 3 FOR 4)::int, 11, 15) < current_date
+ ORDER BY start DESC
+ LIMIT 1;
+
+CREATE TEMP TABLE lv AS
+SELECT lehrveranstaltung_id AS lv_id, semester
+ FROM lehre.tbl_lehrveranstaltung
+ WHERE lehrveranstaltung_id = 5221;
+
+CREATE TEMP TABLE stud AS
+SELECT id AS person_id, 5000 + id AS prestudent_id, 's525b' || id AS uid, vorname
+ FROM (VALUES (513, 'Eins'), (514, 'Zwei')) AS g(id, vorname);
+
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM sem) THEN
+ RAISE EXCEPTION 'No Studiensemester found, the base dump is missing.';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM sem_frist) THEN
+ RAISE EXCEPTION 'No Sommersemester with a passed grade entry deadline.';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM lv) THEN
+ RAISE EXCEPTION 'LV 5221 missing, apply group studiengang_5.';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM lehre.tbl_lehreinheit WHERE lehreinheit_id = 51101) THEN
+ RAISE EXCEPTION 'Lehreinheit 51101 missing, apply group benotungstool_noten first.';
+ END IF;
+ IF (SELECT count(*) FROM public.tbl_mitarbeiter WHERE mitarbeiter_uid IN ('demoadmin', 'demolektor1')) <> 2 THEN
+ RAISE EXCEPTION 'demoadmin or demolektor1 missing, apply BS002_mitarbeiter.sql.';
+ END IF;
+END $$;
+
+-- ------------------------------------------------------------------------------------- students
+INSERT INTO public.tbl_person (person_id, vorname, nachname, gebdatum, geschlecht, aktiv)
+SELECT person_id, vorname, 'Zweitgruppe', '2000-01-15', 'm', true
+ FROM stud
+ ON CONFLICT DO NOTHING;
+
+INSERT INTO public.tbl_prestudent (prestudent_id, person_id, studiengang_kz)
+SELECT prestudent_id, person_id, 5
+ FROM stud
+ ON CONFLICT DO NOTHING;
+
+INSERT INTO public.tbl_prestudentstatus
+ (prestudent_id, status_kurzbz, studiensemester_kurzbz, ausbildungssemester)
+SELECT s.prestudent_id, 'Student', sem.kurzbz, lv.semester
+ FROM stud s, sem, lv
+ ON CONFLICT DO NOTHING;
+
+INSERT INTO public.tbl_benutzer (uid, person_id, aktiv)
+SELECT uid, person_id, true
+ FROM stud
+ ON CONFLICT DO NOTHING;
+
+INSERT INTO public.tbl_student
+ (student_uid, matrikelnr, prestudent_id, studiengang_kz, semester, verband, gruppe)
+SELECT s.uid, '2510005' || s.person_id, s.prestudent_id, 5, lv.semester, '', ''
+ FROM stud s, lv
+ ON CONFLICT DO NOTHING;
+
+INSERT INTO public.tbl_studentlehrverband
+ (student_uid, studiensemester_kurzbz, studiengang_kz, semester, verband, gruppe)
+SELECT s.uid, sem.kurzbz, 5, lv.semester, '', ''
+ FROM stud s, sem, lv
+ ON CONFLICT DO NOTHING;
+
+-- ------------------------------------------------------------------------------ Lehreinheit 51103
+INSERT INTO lehre.tbl_lehreinheit
+ (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrform_kurzbz,
+ wochenrythmus, raumtyp, raumtypalternativ, sprache, lehrfach_id)
+SELECT 51103, lv.lv_id, sem.kurzbz, 'VO', 1, 'Dummy', 'Dummy', 'German', lv.lv_id
+ FROM lv, sem
+ ON CONFLICT DO NOTHING;
+
+UPDATE lehre.tbl_lehreinheit le
+ SET studiensemester_kurzbz = sem.kurzbz
+ FROM sem
+ WHERE le.lehreinheit_id = 51103
+ AND le.studiensemester_kurzbz IS DISTINCT FROM sem.kurzbz;
+
+INSERT INTO lehre.tbl_lehreinheitmitarbeiter (lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz)
+SELECT 51103, l.uid, 'Lektor'
+ FROM (VALUES ('demoadmin'), ('demolektor1')) AS l(uid)
+ ON CONFLICT DO NOTHING;
+
+INSERT INTO public.tbl_gruppe (gruppe_kurzbz, studiengang_kz, semester, bezeichnung, aktiv)
+SELECT 'GRP_CYNOTEN_Z', 5, semester, 'Cypress Noten Zweitgruppe', true
+ FROM lv
+ ON CONFLICT DO NOTHING;
+
+INSERT INTO lehre.tbl_lehreinheitgruppe (lehreinheit_id, studiengang_kz, semester, gruppe_kurzbz)
+SELECT 51103, 5, lv.semester, 'GRP_CYNOTEN_Z'
+ FROM lv
+ WHERE NOT EXISTS (SELECT 1 FROM lehre.tbl_lehreinheitgruppe g
+ WHERE g.lehreinheit_id = 51103 AND g.gruppe_kurzbz = 'GRP_CYNOTEN_Z');
+
+-- the students of 51103 are members of this group only, so each has exactly one Lehreinheit
+INSERT INTO public.tbl_benutzergruppe (uid, gruppe_kurzbz, studiensemester_kurzbz)
+SELECT s.uid, 'GRP_CYNOTEN_Z', sem.kurzbz
+ FROM stud s, sem
+ ON CONFLICT (uid, gruppe_kurzbz) DO UPDATE
+ SET studiensemester_kurzbz = EXCLUDED.studiensemester_kurzbz;
+
+-- ------------------------------------------------------------------------------ Lehreinheit 51104
+-- getLvForLektorInSemester needs no group, only the Lehreinheit and its Lektor
+INSERT INTO lehre.tbl_lehreinheit
+ (lehreinheit_id, lehrveranstaltung_id, studiensemester_kurzbz, lehrform_kurzbz,
+ wochenrythmus, raumtyp, raumtypalternativ, sprache, lehrfach_id)
+SELECT 51104, lv.lv_id, sf.kurzbz, 'VO', 1, 'Dummy', 'Dummy', 'German', lv.lv_id
+ FROM lv, sem_frist sf
+ ON CONFLICT DO NOTHING;
+
+UPDATE lehre.tbl_lehreinheit le
+ SET studiensemester_kurzbz = sf.kurzbz
+ FROM sem_frist sf
+ WHERE le.lehreinheit_id = 51104
+ AND le.studiensemester_kurzbz IS DISTINCT FROM sf.kurzbz;
+
+INSERT INTO lehre.tbl_lehreinheitmitarbeiter (lehreinheit_id, mitarbeiter_uid, lehrfunktion_kurzbz)
+VALUES (51104, 'demolektor1', 'Lektor')
+ ON CONFLICT DO NOTHING;
+
+-- ---------------------------------------------------------------------------------------- Vorlagen
+INSERT INTO public.tbl_vorlage (vorlage_kurzbz, bezeichnung, anmerkung, mimetype)
+VALUES ('Notenfreigabe', 'Notenfreigabe', NULL, 'text/html'),
+ ('Sancho_Mail_Template', 'Sancho Mail Template', 'Demo layout of the test instance', 'text/html')
+ ON CONFLICT (vorlage_kurzbz) DO NOTHING;
+
+-- Placeholders of Noten::sendFreigabeEmail: lektor, lvaname, neuenotencount, studlist, adressen.
+-- An existing active text stays. Studiengang 0 and oe 'etw' are the root, as for the other Vorlagen.
+INSERT INTO public.tbl_vorlagestudiengang
+ (vorlage_kurzbz, studiengang_kz, version, text, oe_kurzbz, aktiv, insertamum, insertvon)
+SELECT v.kurzbz, 0, 1, v.text, 'etw', true, now(), 'seeder'
+ FROM (VALUES
+ ('Notenfreigabe',
+ '{lektor} hat {neuenotencount} Note(n) freigegeben: {lvaname}
{studlist}Empfänger: {adressen}
'),
+ ('Sancho_Mail_Template', '{content}')
+ ) AS v(kurzbz, text)
+ WHERE NOT EXISTS (SELECT 1 FROM public.tbl_vorlagestudiengang x
+ WHERE x.vorlage_kurzbz = v.kurzbz AND x.aktiv);
+
+COMMIT;
+
+DO $$
+BEGIN
+ IF (SELECT count(*) FROM campus.vw_student_lehrveranstaltung v, sem
+ WHERE v.lehreinheit_id = 51103 AND v.studiensemester_kurzbz = sem.kurzbz) = 0 THEN
+ RAISE EXCEPTION 'No students in Lehreinheit 51103 (%), fixture unusable.', (SELECT kurzbz FROM sem);
+ END IF;
+END $$;
+
+SELECT (SELECT kurzbz FROM sem) AS semester,
+ (SELECT kurzbz FROM sem_frist) AS semester_frist,
+ (SELECT count(*) FROM campus.vw_student_lehrveranstaltung v, sem
+ WHERE v.lehreinheit_id = 51103 AND v.studiensemester_kurzbz = sem.kurzbz) AS le51103_studenten,
+ (SELECT count(*) FROM lehre.tbl_lehreinheitmitarbeiter
+ WHERE lehreinheit_id = 51103) AS le51103_lektoren,
+ (SELECT count(*) FROM public.tbl_vorlagestudiengang
+ WHERE vorlage_kurzbz IN ('Notenfreigabe', 'Sancho_Mail_Template') AND aktiv) AS vorlagen;
diff --git a/system/setup_testinstance.php b/system/setup_testinstance.php
deleted file mode 100644
index f76191be0..000000000
--- a/system/setup_testinstance.php
+++ /dev/null
@@ -1,229 +0,0 @@
- FHComplete-3.3.sql ->
- * checksystem.php -> seeders. This script only adds what db_setup.sh does not cover — addon
- * dbupdates, extension SQL and a closing health check.
- *
- * needs psql and db_setup.sh. Connection data comes from config/system.config.inc.php.
- */
-
-if (php_sapi_name() !== 'cli') {
- http_response_code(403);
- exit("Command line only.\n");
-}
-
-error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE & ~E_WARNING);
-set_time_limit(0);
-ini_set('memory_limit', '1024M');
-
-define('SETUP_ROOT', dirname(__DIR__));
-define('DB_SETUP_SH', '/home/testing/fhc-coreutils/tests/automated/db_setup.sh');
-
-function out($msg = '') { fwrite(STDOUT, $msg . PHP_EOL); }
-function step($msg) { out(''); out('=== ' . $msg . ' ' . str_repeat('=', max(0, 62 - strlen($msg)))); }
-function info($msg) { out(' ' . $msg); }
-function ok($msg) { out(' + ' . $msg); }
-function warn($msg) { out(' ! ' . $msg); }
-function bail($msg) { out(''); out(' X ' . $msg); out(''); exit(1); }
-
-function loadConfig()
-{
- $file = SETUP_ROOT . '/config/system.config.inc.php';
- if (!is_file($file)) bail("Config not found: $file");
-
- require_once($file);
-
- // the FHC config sets error_reporting(E_ALL); restore ours, or every include of the legacy
- // classes prints deprecation notices over the output
- error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE & ~E_WARNING);
-
- foreach (['DB_HOST', 'DB_NAME', 'DB_USER'] as $const) {
- if (!defined($const)) bail("$const is not defined in $file.");
- }
- putenv('PGPASSWORD=' . (defined('DB_PASSWORD') ? DB_PASSWORD : ''));
-}
-
-function connectDb()
-{
- $conn = @pg_connect(sprintf(
- 'host=%s port=%s dbname=%s user=%s password=%s',
- DB_HOST, defined('DB_PORT') ? DB_PORT : '5432', DB_NAME, DB_USER,
- defined('DB_PASSWORD') ? DB_PASSWORD : ''
- ));
- if ($conn === false) bail('Cannot connect to ' . DB_NAME . '@' . DB_HOST . ' as ' . DB_USER . '.');
- return $conn;
-}
-
-function scalar($conn, $sql)
-{
- $res = @pg_query($conn, $sql);
- if ($res === false) return null;
- $row = pg_fetch_row($res);
- return $row ? $row[0] : null;
-}
-
-/** Runs one .sql file. Returns the error count; "already exists" is normal on a repeat run. */
-function psqlFile($file)
-{
- exec(sprintf('psql -h %s -p %s -U %s -d %s -f %s 2>&1',
- escapeshellarg(DB_HOST), escapeshellarg(defined('DB_PORT') ? DB_PORT : '5432'),
- escapeshellarg(DB_USER), escapeshellarg(DB_NAME), escapeshellarg($file)), $lines);
-
- $errors = 0;
- foreach ($lines as $line) {
- if (stripos($line, 'ERROR') !== false && stripos($line, 'already exists') === false) $errors++;
- }
- return $errors;
-}
-
-/** Guards, before anything is dropped: --setup empties the target, so refuse anything but cy-*. */
-function preflight()
-{
- step('preflight');
-
- if (!shell_exec('command -v psql')) bail('psql is missing. Run this on the dev machine.');
- if (!is_file(DB_SETUP_SH)) bail('db_setup.sh not found: ' . DB_SETUP_SH);
-
- if (strpos(DB_NAME, 'cy-') !== 0) {
- bail("Target is '" . DB_NAME . "'. --setup drops every schema and only touches cy-* databases.");
- }
-
- $seeders = glob(SETUP_ROOT . '/system/seeders/*.sql');
- if (!$seeders) bail('No seeders in ' . SETUP_ROOT . '/system/seeders/ — the rebuild would leave an empty database.');
-
- ok(count($seeders) . ' seeders, target ' . DB_NAME);
-}
-
-/** Output is passed through verbatim, checksystem.php's HTML included. */
-function rebuild()
-{
- step('db_setup.sh');
- info('truncate -> FHComplete-3.3.sql -> checksystem.php -> seeders');
- out('');
-
- passthru(escapeshellarg(DB_SETUP_SH) . ' ' . escapeshellarg(SETUP_ROOT), $code);
- if ($code !== 0) bail('db_setup.sh failed with exit code ' . $code . '.');
-
- ok('database rebuilt');
-}
-
-/**
- * Addon DB updates. Their install.php files are web installers and empty stubs; the DDL sits in
- * addons//system/dbupdate_*.php and expects CHECKSYSTEM and a $db in scope.
- */
-function runAddons()
-{
- step('addons');
-
- $files = glob(SETUP_ROOT . '/addons/*/system/dbupdate_*.php');
- if (!$files) { info('none found'); return; }
-
- if (!defined('CHECKSYSTEM')) define('CHECKSYSTEM', 'setup_testinstance');
- require_once(SETUP_ROOT . '/include/basis_db.class.php');
- $db = new basis_db();
-
- foreach ($files as $file) {
- $name = basename(dirname(dirname($file))) . '/' . basename($file);
- ob_start();
- try {
- require($file);
- ob_end_clean();
- ok($name);
- } catch (Throwable $t) {
- ob_end_clean();
- warn($name . ': ' . $t->getMessage());
- }
- }
-}
-
-/** Extension SQL, for an instance that has the files but never ran their sql//*.sql. */
-function runExtensions()
-{
- step('extensions');
-
- $dirs = glob(SETUP_ROOT . '/application/extensions/*/sql', GLOB_ONLYDIR);
- if (!$dirs) { info('none found'); return; }
-
- foreach ($dirs as $dir) {
- // every version the extension ships, oldest first; a test instance always wants the newest schema
- $files = glob($dir . '/*/*.sql') ?: [];
- sort($files, SORT_NATURAL);
-
- $errors = 0;
- foreach ($files as $file) $errors += psqlFile($file);
-
- $label = sprintf('%s (%d file(s)%s)', basename(dirname($dir)), count($files), $errors ? ", $errors errors" : '');
- if ($errors) warn($label); else ok($label);
- }
-}
-
-/**
- * General signs of a usable instance only. Whether a given suite's fixture is in place is that
- * suite's own business (see tests/cypress/tools/dbCheck.js).
- */
-function verify($conn)
-{
- step('verify');
-
- $checks = [
- // dump applied
- ['Tables', "SELECT count(*) FROM information_schema.tables
- WHERE table_schema IN ('public','lehre','campus')", function ($v) { return $v > 200; }],
- // checksystem.php ran
- ['Phrases', "SELECT count(*) FROM system.tbl_phrase", function ($v) { return $v > 0; }],
- ['Permissions', "SELECT count(*) FROM system.tbl_berechtigung", function ($v) { return $v > 0; }],
- // seeders ran
- ['Studiensemester', "SELECT count(*) FROM public.tbl_studiensemester
- WHERE start <= now()", function ($v) { return $v > 0; }],
- ['Mitarbeiter', "SELECT count(*) FROM public.tbl_mitarbeiter", function ($v) { return $v > 0; }],
- ['Studenten', "SELECT count(*) FROM public.tbl_student", function ($v) { return $v > 0; }],
- ];
-
- $failed = 0;
- foreach ($checks as list($label, $sql, $test)) {
- $value = scalar($conn, $sql);
- $passed = $test($value);
- if (!$passed) $failed++;
- out(sprintf(' [%s] %-20s %s', $passed ? 'OK ' : 'XX ', $label, $value === null ? '(null)' : $value));
- }
-
- return $failed === 0;
-}
-
-$flags = array_slice($argv, 1);
-$setup = in_array('--setup', $flags, true);
-$verify = in_array('--verify', $flags, true);
-
-// --setup must be spelled out: the run empties the target database.
-if (!$setup && !$verify) {
- out('');
- out(' php system/setup_testinstance.php --setup rebuild the database, then verify');
- out(' php system/setup_testinstance.php --verify verify only, write nothing');
- out('');
- exit(0);
-}
-
-loadConfig();
-
-out('');
-out(' Target : ' . DB_NAME . ' @ ' . DB_HOST);
-
-if ($setup) {
- preflight();
- rebuild();
- runAddons();
- runExtensions();
-}
-
-$success = verify(connectDb());
-
-out('');
-out($success ? ' Done.' : ' Finished with findings, see above.');
-out('');
-
-exit($success ? 0 : 1);
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 956783f64..142501f5f 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.access.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.access.cy.js
@@ -6,40 +6,30 @@
*/
import { notenApi } from "../../../../support/api/notenApi";
-import {
- expectAuthError,
- expectNotenError,
- expectNotenSuccess,
-} from "../../../../support/helpers/notenErrors";
+import { expectAuthError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import {
attemptDate,
loadNotenContext,
- readLvGesamtnote,
+ readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
+import { skipWenn } from "../../../../support/helpers/notenConfig";
import {
assistenzAuth,
- assistenzConfigured,
assistenzKontext,
assistenzLvMitLehreinheiten,
+ requireAssistenz,
} from "../../../../support/helpers/notenAssistenz";
const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten";
-const teacherConfigured = () =>
- Boolean(Cypress.env("NOTEN_TEACHER_USER") && Cypress.env("NOTEN_FOREIGN_LV_ID"));
+const teacherConfigured = () => Boolean(Cypress.env("NOTEN_TEACHER_USER") && Cypress.env("NOTEN_FOREIGN_LV_ID"));
const getAs = (auth, path, qs) =>
cy.request({ method: "GET", url: `${NOTEN_API}/${path}`, qs, auth, failOnStatusCode: false });
-const skipOhneAssistenz = (test) => {
- if (assistenzConfigured()) return;
- Cypress.log({ name: "skip", message: "Übersprungen: NOTEN_ASSISTENZ_USER / NOTEN_ASSISTENZ_PASSWORD fehlen." });
- test.skip();
-};
-
/** A getStudentenNoten call as an explicitly chosen user (or with no credentials at all). */
const getStudentenNotenAs = (auth, lvId, semKurzbz) =>
cy.request({
@@ -50,7 +40,7 @@ const getStudentenNotenAs = (auth, lvId, semKurzbz) =>
failOnStatusCode: false,
});
-describe("Noten API - access control", () => {
+describe("Noten API - Zugriffsschutz", () => {
let ctx;
before(() => {
@@ -59,11 +49,11 @@ describe("Noten API - access control", () => {
});
});
- it("rejects requests that are not authenticated", () => {
+ it("lehnt eine Anfrage ohne Anmeldung ab", () => {
cy.clearAllCookies(); // otherwise the previous login is still active
- cy.request({ method: "GET", url: `${NOTEN_API}/getCisConfig`, failOnStatusCode: false }).then(
- (response) => expectAuthError(response),
+ cy.request({ method: "GET", url: `${NOTEN_API}/getCisConfig`, failOnStatusCode: false }).then((response) =>
+ expectAuthError(response),
);
cy.request({
@@ -74,8 +64,8 @@ describe("Noten API - access control", () => {
}).then((response) => expectAuthError(response));
});
- describe("the configured API user", () => {
- it("can read the students of the test LV", () => {
+ describe("der konfigurierte API-Benutzer", () => {
+ it("liest die Studierenden der Test-LV", () => {
notenApi.getStudentenNoten(ctx.lvId, ctx.semKurzbz).then((response) => {
const data = expectNotenSuccess(response, "getStudentenNoten on the own/admin LV");
expect(data[0], "student list").to.be.an("array").and.not.be.empty;
@@ -83,18 +73,15 @@ describe("Noten API - access control", () => {
});
});
- describe("teacher scoping", () => {
+ describe("Scope der Lehrperson", () => {
beforeEach(function () {
- if (!teacherConfigured()) {
- Cypress.log({
- name: "skip",
- message:
- "Skipped: needs NOTEN_TEACHER_USER / NOTEN_TEACHER_PASSWORD and NOTEN_FOREIGN_LV_ID " +
- "(an LV that teacher does NOT teach). This API has no impersonation, so a second " +
- "real login is required.",
- });
- this.skip();
- }
+ skipWenn(
+ this,
+ !teacherConfigured(),
+ "Übersprungen: braucht NOTEN_TEACHER_USER / NOTEN_TEACHER_PASSWORD und NOTEN_FOREIGN_LV_ID " +
+ "(eine LV, die diese Lehrperson NICHT unterrichtet). Die API kennt keine Identitätsübernahme, " +
+ "deshalb braucht der Test ein zweites echtes Konto.",
+ );
// cy.request reuses the session cookie of the previous (admin) call and the server prefers
// it over the Basic header - without this every test here silently runs as the admin user.
cy.clearAllCookies();
@@ -106,17 +93,13 @@ describe("Noten API - access control", () => {
});
// assertLvAccess denies through terminateWithError -> 500 + phrase, not a 401.
- it("denies a teacher access to an LV they do not teach", () => {
- getStudentenNotenAs(
- teacherAuth(),
- Cypress.env("NOTEN_FOREIGN_LV_ID"),
- ctx.semKurzbz,
- ).then((response) => {
+ it("verweigert einer Lehrperson eine fremde LV", () => {
+ getStudentenNotenAs(teacherAuth(), Cypress.env("NOTEN_FOREIGN_LV_ID"), ctx.semKurzbz).then((response) => {
expectNotenError(response, "keineBerechtigungNoten");
});
});
- it("denies a teacher writing a grade in an LV they do not teach", () => {
+ it("verweigert einer Lehrperson die Note in einer fremden LV", () => {
cy.request({
method: "POST",
url: `${NOTEN_API}/saveNotenvorschlag`,
@@ -134,7 +117,7 @@ describe("Noten API - access control", () => {
});
});
- it("denies a teacher the Lehreinheiten of an LV they do not teach", () => {
+ it("verweigert einer Lehrperson die Lehreinheiten einer fremden LV", () => {
getAs(teacherAuth(), "getLehreinheitenFuerLv", {
lv_id: Cypress.env("NOTEN_FOREIGN_LV_ID"),
sem_kurzbz: ctx.semKurzbz,
@@ -147,7 +130,7 @@ describe("Noten API - access control", () => {
describe("getBenotungstoolContext als Assistenz", () => {
beforeEach(function () {
- skipOhneAssistenz(this);
+ requireAssistenz(this);
cy.clearAllCookies();
});
@@ -223,12 +206,12 @@ describe("Noten API - access control", () => {
i >= ctx.students.length
? cy.wrap(null, { log: false })
: cy
- .task("noten:db:andereLvNoten", {
- lvId: ctx.lvId,
- semKurzbz: ctx.semKurzbz,
- studentUid: ctx.students[i].uid,
- })
- .then((anzahl) => (anzahl === 0 ? ctx.students[i] : suche(i + 1)));
+ .task("noten:db:andereLvNoten", {
+ lvId: ctx.lvId,
+ semKurzbz: ctx.semKurzbz,
+ studentUid: ctx.students[i].uid,
+ })
+ .then((anzahl) => (anzahl === 0 ? ctx.students[i] : suche(i + 1)));
suche(0).then((found) => {
student = found;
@@ -236,21 +219,18 @@ describe("Noten API - access control", () => {
});
it("ändert bei lv_id=null keine LV-Note", function () {
- if (!student) {
- Cypress.log({ name: "skip", message: "Übersprungen: jeder Studierende hat LV-Noten in anderen LVs." });
- this.skip();
- }
+ skipWenn(this, !student, "Übersprungen: jeder Studierende hat LV-Noten in anderen LVs.");
const g2 = ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ));
resetNotenState(ctx);
- seedBaseline(ctx, student.uid);
+ seedBaseline(ctx, student);
notenApi
.saveNotenvorschlag(null, ctx.semKurzbz, student.uid, g2)
.then((response) => expectNotenError(response, "wrongParameters"));
- readLvGesamtnote(ctx, student.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
expect(String(row.note), "die LV-Note bleibt").to.eq(String(ctx.notes.negativ));
});
});
@@ -260,7 +240,9 @@ describe("Noten API - access control", () => {
// W10: Der Endpunkt liefert alle Lehreinheiten der LV, für jede Rolle mit Zugriff auf die LV.
describe("getLehreinheitenFuerLv", () => {
const lehreinheitenDerLv = (lvId) =>
- cy.task("noten:db:lehreinheitenDerLv", { lvId, semKurzbz: ctx.semKurzbz }).then((ids) => ids.map(String).sort());
+ cy
+ .task("noten:db:lehreinheitenDerLv", { lvId, semKurzbz: ctx.semKurzbz })
+ .then((ids) => ids.map(String).sort());
// die Abfrage liefert eine Zeile je Gruppe, eine Lehreinheit kann also mehrfach vorkommen
const idsOf = (rows) => [...new Set(rows.map((r) => String(r.lehreinheit_id)))].sort();
@@ -272,8 +254,18 @@ describe("Noten API - access control", () => {
const rows = expectNotenSuccess(response, "getLehreinheitenFuerLv");
expect(rows, "Lehreinheiten").to.be.an("array").and.not.be.empty;
expect(rows[0], "Felder einer Lehreinheit").to.include.all.keys(
- "lehreinheit_id", "lehrveranstaltung_id", "lehrform_kurzbz", "direktinskription", "semester",
- "verband", "gruppe", "gruppe_kurzbz", "kurzbz", "kurzbzlang", "termincount", "studentcount",
+ "lehreinheit_id",
+ "lehrveranstaltung_id",
+ "lehrform_kurzbz",
+ "direktinskription",
+ "semester",
+ "verband",
+ "gruppe",
+ "gruppe_kurzbz",
+ "kurzbz",
+ "kurzbzlang",
+ "termincount",
+ "studentcount",
);
lehreinheitenDerLv(ctx.lvId).then((ids) => {
@@ -283,7 +275,7 @@ describe("Noten API - access control", () => {
});
it("liefert einer Assistenz die Lehreinheiten einer LV ihres Studiengangs", function () {
- skipOhneAssistenz(this);
+ requireAssistenz(this);
cy.clearAllCookies();
assistenzLvMitLehreinheiten(ctx.semKurzbz).then((ziel) => {
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 cab6e785e..e5bea18ea 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js
@@ -4,31 +4,24 @@
*/
import { notenApi } from "../../../../support/api/notenApi";
-import {
- expectBulkRowAccepted,
- expectBulkRowError,
- expectNotenSuccess,
-} from "../../../../support/helpers/notenErrors";
+import { expectBulkRowAccepted, expectBulkRowError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import {
requireKonfiguration,
+ requireNotenModus,
requirePunkteModus,
requireWiederholung,
} from "../../../../support/helpers/notenConfig";
import {
attemptDate,
loadNotenContext,
- readLvGesamtnote,
+ readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
-import {
- addPruefung,
- attemptsOfStudent,
- readState,
-} from "../../../../support/helpers/notenScenario";
+import { addPruefung, attemptsOfStudent, readStateViaApi } from "../../../../support/helpers/notenScenario";
-describe("Noten API - bulk paths", () => {
+describe("Noten API - Sammelpfade", () => {
let ctx;
before(() => {
@@ -58,13 +51,13 @@ describe("Noten API - bulk paths", () => {
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
});
- it("rejects only the row that breaks a §1 rule and accepts the rest", () => {
+ it("lehnt nur die Zeile gegen eine §1-Regel ab und nimmt die übrigen an", () => {
const atCap = ctx.students[0];
const fresh = ctx.students[1];
resetNotenState(ctx);
- seedBaseline(ctx, atCap.uid);
- seedBaseline(ctx, fresh.uid);
+ seedBaseline(ctx, atCap);
+ seedBaseline(ctx, fresh);
const datum = bisZurGrenze(atCap);
@@ -86,7 +79,7 @@ describe("Noten API - bulk paths", () => {
expectBulkRowAccepted(data, fresh.uid);
});
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const created = attemptsOfStudent(data, fresh.uid);
expect(
created.map((p) => String(p.datum).slice(0, 10)),
@@ -94,7 +87,6 @@ describe("Noten API - bulk paths", () => {
).to.include(datum);
});
});
-
});
describe("savePruefungenBulk", () => {
@@ -104,20 +96,17 @@ describe("Noten API - bulk paths", () => {
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
});
- it("applies the §1 rules per row", function () {
- if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
- // im Punktemodus leitet der Endpunkt die Note aus den Punkten ab; die Regelprüfung
- // deckt der punktebasierte Test unten ab
- Cypress.log({ name: "skip", message: "Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv, siehe den punktebasierten Test." });
- this.skip();
- }
+ 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);
const atCap = ctx.students[0];
const fresh = ctx.students[1];
resetNotenState(ctx);
- seedBaseline(ctx, atCap.uid);
- seedBaseline(ctx, fresh.uid);
+ seedBaseline(ctx, atCap);
+ seedBaseline(ctx, fresh);
const datum = bisZurGrenze(atCap);
@@ -157,8 +146,8 @@ describe("Noten API - bulk paths", () => {
const punkte = 70;
resetNotenState(ctx);
- seedBaseline(ctx, atCap.uid);
- seedBaseline(ctx, fresh.uid);
+ seedBaseline(ctx, atCap);
+ seedBaseline(ctx, fresh);
const datum = bisZurGrenze(atCap);
const zeile = (student) => ({
@@ -178,8 +167,10 @@ describe("Noten API - bulk paths", () => {
notenApi.getNoteByPunkte(punkte, ctx.lvId, ctx.semKurzbz).then((antwort) => {
const erwartet = antwort.body.data;
- readState(ctx).then((data) => {
- const neu = attemptsOfStudent(data, fresh.uid).find((p) => String(p.datum).slice(0, 10) === datum);
+ readStateViaApi(ctx).then((data) => {
+ const neu = 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));
});
@@ -189,12 +180,9 @@ describe("Noten API - bulk paths", () => {
});
describe("saveNotenvorschlagBulk", () => {
- it("writes an LV note for every row", function () {
- if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
- // im Punktemodus kommt die Note aus dem Notenschlüssel, siehe den Punktemodus-Block
- Cypress.log({ name: "skip", message: "Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv." });
- this.skip();
- }
+ 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);
const [a, b] = ctx.students;
@@ -212,12 +200,12 @@ describe("Noten API - bulk paths", () => {
expectBulkRowAccepted(data, b.uid);
});
- readLvGesamtnote(ctx, a.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, a.uid).then((row) => {
expect(row, `row for ${a.uid}`).to.not.be.null;
expect(String(row.note)).to.eq(String(ctx.gradeNotes[0]));
});
- readLvGesamtnote(ctx, b.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, b.uid).then((row) => {
expect(row, `row for ${b.uid}`).to.not.be.null;
expect(String(row.note)).to.eq(String(ctx.gradeNotes[1]));
});
@@ -246,7 +234,7 @@ describe("Noten API - bulk paths", () => {
expectBulkRowAccepted(data, mitPunkten.uid);
});
- readLvGesamtnote(ctx, ohnePunkte.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, ohnePunkte.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 f3d9e51d1..035f5beb6 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js
@@ -18,7 +18,7 @@ import {
attemptDate,
baselineBenotungsdatum,
loadNotenContext,
- readLvGesamtnote,
+ readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
@@ -29,12 +29,11 @@ import {
attemptsOfStudent,
givenBaseline,
lvNoteOf,
- readState,
+ readStateViaApi,
verlaufOfStudent,
} from "../../../../support/helpers/notenScenario";
-const freigabePassword = () =>
- Cypress.env("NOTEN_FREIGABE_PASSWORD") || Cypress.env("adminpassword");
+const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || Cypress.env("adminpassword");
/** The payload shape saveStudentenNoten expects per student (see the studlist builder). */
const notenPayload = (student, noteBezeichnung) => ({
@@ -55,8 +54,8 @@ describe("Noten API - Notenfreigabe", () => {
});
});
- describe("password gate", () => {
- it("rejects a Freigabe with a wrong password and changes nothing", function () {
+ 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);
requireDbReset();
@@ -64,53 +63,42 @@ describe("Noten API - Notenfreigabe", () => {
const student = ctx.students[0];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, {
+ seedBaseline(ctx, student, {
note: ctx.gradeNotes[0],
freigegeben: false,
benotungsdatum: baselineBenotungsdatum(ctx),
});
notenApi
- .saveStudentenNoten(
- "definitely-not-the-password",
- [notenPayload(student)],
- ctx.lvId,
- ctx.semKurzbz,
- )
+ .saveStudentenNoten("definitely-not-the-password", [notenPayload(student)], ctx.lvId, ctx.semKurzbz)
.then((response) => {
expectNotenError(response, "wrongPassword");
});
- readLvGesamtnote(ctx, student.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
expect(row, "the row still exists").to.not.be.null;
expect(row.freigabedatum, "a rejected Freigabe must not stamp freigabedatum").to.be.null;
});
});
-
});
- describe("happy path", () => {
+ describe("der normale Ablauf", () => {
beforeEach(() => {
requireDbReset();
});
- it("stamps freigabedatum on a changed note", () => {
+ it("setzt das freigabedatum auf einer geänderten Note", () => {
const student = ctx.students[0];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, {
+ seedBaseline(ctx, student, {
note: ctx.gradeNotes[0],
freigegeben: false,
benotungsdatum: baselineBenotungsdatum(ctx),
});
notenApi
- .saveStudentenNoten(
- freigabePassword(),
- [notenPayload(student)],
- ctx.lvId,
- ctx.semKurzbz,
- )
+ .saveStudentenNoten(freigabePassword(), [notenPayload(student)], ctx.lvId, ctx.semKurzbz)
.then((response) => {
const data = expectNotenSuccess(response, "saveStudentenNoten");
expect(data, "freigegebene rows are reported back").to.be.an("array");
@@ -122,13 +110,13 @@ describe("Noten API - Notenfreigabe", () => {
expect(entry.verlauf.pruefungen, "inklusive der Termine").to.be.an("array");
});
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const grades = lvNoteOf(data, student.uid);
expect(grades.freigabedatum, "the note is now freigegeben").to.exist;
});
// C1: Die Freigabe brach früher vor dem Schreiben ab.
- readLvGesamtnote(ctx, student.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
expect(row.freigabedatum, "C1: die Freigabe schreibt freigabedatum").to.not.be.null;
});
});
@@ -139,17 +127,19 @@ describe("Noten API - Notenfreigabe", () => {
const student = ctx.students[0];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.notes.negativ, freigegeben: false });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: false });
notenApi
.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
- expect(entry.verlauf.pruefungen, "der erste Antritt kommt mit der Antwort zurück").to.have.length(1);
+ expect(entry.verlauf.pruefungen, "der erste Antritt kommt mit der Antwort zurück").to.have.length(
+ 1,
+ );
});
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
expect(attempts, "genau ein Termin").to.have.length(1);
expect(attempts[0].antritt_nr, "als Antritt 1").to.eq(1);
@@ -162,7 +152,7 @@ describe("Noten API - Notenfreigabe", () => {
const student = ctx.students[0];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.notes.negativ, freigegeben: false });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: false });
notenApi
.saveStudentenNoten(freigabePassword(), [notenPayload(student)], ctx.lvId, ctx.semKurzbz)
@@ -171,7 +161,7 @@ describe("Noten API - Notenfreigabe", () => {
expect(entry.freigabedatum, "die Freigabe gilt trotzdem").to.exist;
});
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
expect(attemptsOfStudent(data, student.uid), "kein Termin").to.have.length(0);
expect(verlaufOfStudent(data, student.uid).antrittCount, "die LV-Note zählt als Antritt 1").to.eq(1);
});
@@ -183,32 +173,35 @@ describe("Noten API - Notenfreigabe", () => {
const student = ctx.students[1];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.notes.negativ, freigegeben: false });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: false });
notenApi.saveStudentenNoten("", [notenPayload(student)], ctx.lvId, ctx.semKurzbz).then((response) => {
const data = expectNotenSuccess(response, "Freigabe ohne Passwort");
- expect(data.map((r) => r.uid), "freigegebene Zeilen").to.include(student.uid);
+ expect(
+ data.map((r) => r.uid),
+ "freigegebene Zeilen",
+ ).to.include(student.uid);
});
- readLvGesamtnote(ctx, student.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
expect(row.freigabedatum, "freigabedatum").to.not.be.null;
});
});
// W7: Eine einzelne Wiederholung ist nicht Antritt 1. Die Freigabe überschreibt ihre Note nicht.
- it("keeps the grade of a single repeat", () => {
+ 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.uid, { note: ctx.notes.negativ, freigegeben: false, erstantritt: false });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: false, erstantritt: false });
seedPruefung(ctx, student, { note: g2, datum: attemptDate(ctx, 1), typ: "Termin2" });
notenApi
.saveStudentenNoten(freigabePassword(), [notenPayload(student)], ctx.lvId, ctx.semKurzbz)
.then((response) => expectNotenSuccess(response, "saveStudentenNoten"));
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
expect(attempts, "the Freigabe adds no exam").to.have.length(1);
expect(String(attempts[0].note), "the repeat keeps its grade").to.eq(String(g2));
@@ -217,12 +210,12 @@ 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.
- it("keeps the date of an exam that already exists", () => {
+ it("behält das Datum einer bestehenden Prüfung", () => {
const student = ctx.students[2];
const pruefungsdatum = attemptDate(ctx, 2);
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, {
+ seedBaseline(ctx, student, {
note: ctx.notes.negativ,
freigegeben: false,
erstantritt: false,
@@ -235,23 +228,21 @@ describe("Noten API - Notenfreigabe", () => {
.saveStudentenNoten(freigabePassword(), [notenPayload(student)], ctx.lvId, ctx.semKurzbz)
.then((response) => expectNotenSuccess(response, "saveStudentenNoten"));
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
expect(attempts, "der Termin bleibt der einzige").to.have.length(1);
- expect(String(attempts[0].datum).slice(0, 10), "mit seinem eigenen Datum").to.eq(
- pruefungsdatum,
- );
+ expect(String(attempts[0].datum).slice(0, 10), "mit seinem eigenen Datum").to.eq(pruefungsdatum);
});
});
- it("only touches rows whose benotungsdatum is newer than their freigabedatum", () => {
+ it("ändert nur Zeilen mit einem benotungsdatum nach dem freigabedatum", () => {
const changed = ctx.students[0];
const alreadyReleased = ctx.students[1];
resetNotenState(ctx);
// changed: benotungsdatum after freigabedatum -> must be re-freigegeben
- seedBaseline(ctx, changed.uid, {
+ seedBaseline(ctx, changed, {
note: ctx.gradeNotes[0],
freigegeben: true,
freigabedatum: `${ctx.semKurzbz.slice(2, 6)}-01-05 08:00:00`,
@@ -259,7 +250,7 @@ describe("Noten API - Notenfreigabe", () => {
});
// already freigegeben and untouched since -> must be left alone
- seedBaseline(ctx, alreadyReleased.uid, {
+ seedBaseline(ctx, alreadyReleased, {
note: ctx.gradeNotes[0],
freigegeben: true,
freigabedatum: `${ctx.semKurzbz.slice(2, 6)}-01-10 08:00:00`,
@@ -284,7 +275,7 @@ describe("Noten API - Notenfreigabe", () => {
).to.not.include(alreadyReleased.uid);
});
- readLvGesamtnote(ctx, alreadyReleased.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, alreadyReleased.uid).then((row) => {
expect(
new Date(row.freigabedatum).toISOString().slice(0, 10),
"the untouched row keeps its original freigabedatum",
@@ -318,7 +309,7 @@ describe("Noten API - Notenfreigabe", () => {
const student = ctx.students[3];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.notes.negativ, freigegeben: false });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: false });
notenApi
.saveStudentenNoten(freigabePassword(), [notenPayload(student)], ctx.lvId, ctx.semKurzbz)
@@ -327,7 +318,7 @@ describe("Noten API - Notenfreigabe", () => {
expect(entry, `${student.uid} ist freigegeben`).to.exist;
});
- readLvGesamtnote(ctx, student.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
expect(row.freigabedatum, "freigabedatum").to.not.be.null;
});
});
@@ -353,7 +344,7 @@ describe("Noten API - Notenfreigabe", () => {
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, andereNote())
.then((response) => expectNotenError(response, "freigabeEndgueltig"));
- readLvGesamtnote(ctx, student.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
expect(String(row.note), "die LV-Note bleibt").to.eq(String(ctx.notes.negativ));
});
});
@@ -367,7 +358,7 @@ describe("Noten API - Notenfreigabe", () => {
expectNotenError(response, "freigabeEndgueltig"),
);
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
expect(attemptsOfStudent(data, student.uid), "nur Antritt 1").to.have.length(1);
});
});
@@ -379,8 +370,8 @@ describe("Noten API - Notenfreigabe", () => {
const [freigegeben, offen] = ctx.students;
resetNotenState(ctx);
- seedBaseline(ctx, freigegeben.uid, { freigegeben: true });
- seedBaseline(ctx, offen.uid, { freigegeben: false });
+ seedBaseline(ctx, freigegeben, { freigegeben: true });
+ seedBaseline(ctx, offen, { freigegeben: false });
notenApi
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
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 adbda29a2..94941a6ea 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js
@@ -6,15 +6,11 @@
* 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.
*
- * Seeder 019 legt das Sommersemester an, in dem der Testbenutzer nach der Frist unterrichtet.
+ * Die Seeder-Gruppe benotungstool_fixture_erweitert legt das Sommersemester an, in dem der Testbenutzer nach der Frist unterrichtet.
*/
import { notenApi } from "../../../../support/api/notenApi";
-import {
- expectNotenError,
- expectNotenSuccess,
- messageMatchesPhrase,
-} from "../../../../support/helpers/notenErrors";
+import { expectNotenError, expectNotenSuccess, messageMatchesPhrase } from "../../../../support/helpers/notenErrors";
import {
attemptDate,
expectedFristString,
@@ -54,7 +50,8 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
// 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.null;
+ expect(abgelaufen[typ], `ein ${typ} mit abgelaufener Frist, in dem der Testbenutzer unterrichtet`).to.not.be
+ .null;
return abgelaufen[typ];
};
@@ -118,7 +115,8 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
sem_kurzbz: paar.semKurzbz,
pruefung_id: null,
}),
- saveNotenvorschlag: (paar) => notenApi.saveNotenvorschlag(paar.lvId, paar.semKurzbz, s0().uid, ctx.gradeNotes[0]),
+ saveNotenvorschlag: (paar) =>
+ notenApi.saveNotenvorschlag(paar.lvId, paar.semKurzbz, s0().uid, ctx.gradeNotes[0]),
saveNotenvorschlagBulk: (paar) =>
notenApi.saveNotenvorschlagBulk(paar.lvId, paar.semKurzbz, [
{ uid: s0().uid, note: ctx.gradeNotes[0], punkte: null },
@@ -154,8 +152,10 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", false);
const paar = paarFuer(typ);
- expect(fristHasPassed(paar.semKurzbz, fristSS(), fristWS()), `die Frist von ${paar.semKurzbz} ist vorbei`)
- .to.be.true;
+ expect(
+ fristHasPassed(paar.semKurzbz, fristSS(), fristWS()),
+ `die Frist von ${paar.semKurzbz} ist vorbei`,
+ ).to.be.true;
aufruf(paar).then((response) => {
expectNotenError(response, "noteneintragungsfristVorbei");
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 c776e8495..a74a2c74c 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js
@@ -7,7 +7,7 @@
import { notenApi } from "../../../../support/api/notenApi";
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
-import { requirePunkteModus } from "../../../../support/helpers/notenConfig";
+import { requirePunkteModus, skipWenn } from "../../../../support/helpers/notenConfig";
import {
attemptDate,
loadNotenContext,
@@ -15,7 +15,7 @@ import {
resetNotenState,
seedBaseline,
} from "../../../../support/helpers/notenTestData";
-import { attemptsOfStudent, givenBaseline, readState } from "../../../../support/helpers/notenScenario";
+import { attemptsOfStudent, givenBaseline, readStateViaApi } from "../../../../support/helpers/notenScenario";
const MAX_PUNKTE = 100;
@@ -44,7 +44,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
return cy.then(() => observed);
};
- it("maps every point value through a monotonic, exact grade scale", () => {
+ it("bildet jeden Punktewert auf eine monotone und exakte Notenskala ab", () => {
sweepScale().then((observed) => {
const graded = observed.filter((entry) => entry.note !== null && entry.note !== undefined);
@@ -84,11 +84,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
}
}
- cy.log(
- `Notenschlüssel of LV ${lvId}: ${boundaries
- .map((b) => `>=${b.punkte} -> ${b.note}`)
- .join(", ")}`,
- );
+ cy.log(`Notenschlüssel of LV ${lvId}: ${boundaries.map((b) => `>=${b.punkte} -> ${b.note}`).join(", ")}`);
expect(
boundaries.length,
@@ -128,15 +124,12 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
beforeEach(function () {
requirePunkteModus(this, ctx);
- if (schwelleUnterNull) {
- Cypress.log({ name: "skip", message: "Übersprungen: der Notenschlüssel hat eine Schwelle unter 0 Punkten." });
- this.skip();
- }
+ skipWenn(this, schwelleUnterNull, "Übersprungen: der Notenschlüssel hat eine Schwelle unter 0 Punkten.");
requireDbReset();
});
const expectNurAntritt1 = (students) =>
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
students.forEach((s) => {
expect(attemptsOfStudent(data, s.uid), `Prüfungen von ${s.uid}`).to.have.length(1);
});
@@ -167,7 +160,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
const students = ctx.students.slice(0, 2);
resetNotenState(ctx);
- students.forEach((s) => seedBaseline(ctx, s.uid));
+ students.forEach((s) => seedBaseline(ctx, s));
notenApi
.createPruefungen(
@@ -183,5 +176,4 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
expectNurAntritt1(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 1dff10b94..9740f1c13 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js
@@ -14,7 +14,7 @@ import {
baselineBenotungsdatum,
baselineDate,
loadNotenContext,
- readLvGesamtnote,
+ readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
@@ -24,7 +24,7 @@ import {
addPruefung,
attemptsOfStudent,
lvNoteOf,
- readState,
+ readStateViaApi,
verlaufOfStudent,
} from "../../../../support/helpers/notenScenario";
@@ -38,41 +38,38 @@ describe("Noten API - Notenvorschlag", () => {
});
});
- const readRow = (uid) => readLvGesamtnote(ctx, uid);
+ const readRow = (uid) => readLvGesamtnoteViaDb(ctx, uid);
- describe("saving a Notenvorschlag on a student without a grade", () => {
- it("returns the stored note with a benotungsdatum and no freigabedatum", () => {
+ describe("ein Notenvorschlag für einen Studierenden ohne Note", () => {
+ it("gibt die gespeicherte Note mit benotungsdatum und ohne freigabedatum zurück", () => {
const student = ctx.students[0];
resetNotenState(ctx);
- notenApi
- .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0])
- .then((response) => {
- const data = expectNotenSuccess(response, "saveNotenvorschlag");
- expect(data, "saveNotenvorschlag returns [ lvgesamtnote ]").to.be.an("array").and.not
- .be.empty;
+ notenApi.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0]).then((response) => {
+ const data = expectNotenSuccess(response, "saveNotenvorschlag");
+ expect(data, "saveNotenvorschlag returns [ lvgesamtnote ]").to.be.an("array").and.not.be.empty;
- const row = data[0];
- expect(String(row.note), "stored note").to.eq(String(ctx.gradeNotes[0]));
- expect(row.benotungsdatum, "benotungsdatum is stamped on save").to.exist;
- expect(row.freigabedatum, "a fresh Notenvorschlag is not freigegeben").to.be.oneOf([
- null,
- undefined,
- "",
- ]);
- });
+ const row = data[0];
+ expect(String(row.note), "stored note").to.eq(String(ctx.gradeNotes[0]));
+ expect(row.benotungsdatum, "benotungsdatum is stamped on save").to.exist;
+ expect(row.freigabedatum, "a fresh Notenvorschlag is not freigegeben").to.be.oneOf([
+ null,
+ undefined,
+ "",
+ ]);
+ });
});
// getStudentenNoten liest seit dem Wechsel auf den ungefilterten Getter auch noch nicht
// freigegebene Noten - sonst sind LV-Note und Freigabestatus nach einem Reload leer.
- it("reports the offen note back through getStudentenNoten", () => {
+ it("meldet die offene Note über getStudentenNoten zurück", () => {
const student = ctx.students[0];
resetNotenState(ctx);
notenApi.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0]);
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const grades = lvNoteOf(data, student.uid);
expect(grades, `grades entry for ${student.uid}`).to.exist;
expect(
@@ -93,7 +90,7 @@ describe("Noten API - Notenvorschlag", () => {
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.
- it("writes attempt 1 with the chosen day", function () {
+ it("schreibt Antritt 1 mit dem gewählten Tag", function () {
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
const student = ctx.students[1];
@@ -109,7 +106,7 @@ describe("Noten API - Notenvorschlag", () => {
expect(row.verlauf.antrittCount, "die LV-Note ist Antritt 1").to.eq(1);
expect(row.verlauf.hatWiederholung, "Antritt 1 ist keine Wiederholung").to.be.false;
})
- .then(() => readState(ctx))
+ .then(() => readStateViaApi(ctx))
.then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
expect(attempts, "genau ein Antritt").to.have.length(1);
@@ -118,7 +115,7 @@ describe("Noten API - Notenvorschlag", () => {
});
});
- it("makes the next exam attempt 2, not attempt 1 again", function () {
+ it("macht die nächste Prüfung zu Antritt 2 statt wieder zu Antritt 1", function () {
requireWiederholung(this, ctx);
const student = ctx.students[2];
@@ -126,19 +123,15 @@ describe("Noten API - Notenvorschlag", () => {
resetNotenState(ctx);
notenApi
- .saveNotenvorschlag(
- ctx.lvId, ctx.semKurzbz, student.uid, ctx.notes.negativ, null, baselineDate(ctx),
- )
+ .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notes.negativ, null, baselineDate(ctx))
.then((response) => expectNotenSuccess(response, "übernehmen"));
addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) })
.then((response) => {
const [saved] = expectNotenSuccess(response, "Wiederholung");
- expect(saved.pruefungstyp_kurzbz, "die Wiederholung ist nicht Antritt 1").to.not.eq(
- "Termin1",
- );
+ expect(saved.pruefungstyp_kurzbz, "die Wiederholung ist nicht Antritt 1").to.not.eq("Termin1");
})
- .then(() => readState(ctx))
+ .then(() => readStateViaApi(ctx))
.then((data) => {
const verlauf = verlaufOfStudent(data, student.uid);
expect(verlauf.antrittCount, "die Übernahme und die Prüfung sind zwei Antritte").to.eq(2);
@@ -160,7 +153,7 @@ describe("Noten API - Notenvorschlag", () => {
expect(row.verlauf.antrittCount, "die LV-Note zählt als Antritt 1").to.eq(1);
});
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
expect(attemptsOfStudent(data, student.uid), "kein Termin").to.have.length(0);
});
});
@@ -182,15 +175,17 @@ describe("Noten API - Notenvorschlag", () => {
expectNotenSuccess(response, "erster Termin"),
);
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
expect(attempts, "Antritt 1 und der neue Termin").to.have.length(2);
expect(String(attempts[0].note), "Antritt 1 trägt die LV-Note").to.eq(String(ctx.notes.negativ));
- expect(attempts.map((p) => p.antritt_nr), "Antrittsnummern").to.deep.eq([1, 2]);
+ expect(
+ attempts.map((p) => p.antritt_nr),
+ "Antrittsnummern",
+ ).to.deep.eq([1, 2]);
expect(verlaufOfStudent(data, student.uid).antrittCount, "zwei Antritte").to.eq(2);
});
});
-
});
// W5: Eine Abfrage liest alle LV-Noten der LV. Das Ergebnis je Studierendem bleibt gleich.
@@ -199,10 +194,10 @@ describe("Noten API - Notenvorschlag", () => {
const [freigegeben, offen, ohneNote] = ctx.students;
resetNotenState(ctx);
- seedBaseline(ctx, freigegeben.uid, { freigegeben: true });
- seedBaseline(ctx, offen.uid, { freigegeben: false });
+ seedBaseline(ctx, freigegeben, { freigegeben: true });
+ seedBaseline(ctx, offen, { freigegeben: false });
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const f = lvNoteOf(data, freigegeben.uid);
expect(f.note_lv, "LV-Note der freigegebenen Zeile").to.exist;
expect(f.freigabedatum, "freigabedatum der freigegebenen Zeile").to.exist;
@@ -224,18 +219,20 @@ describe("Noten API - Notenvorschlag", () => {
it("sperrt die Übernahme", () => {
const student = ctx.students[3];
const g2 =
- ctx.notes.positiv ?? ctx.notes.bestnote ?? ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ));
+ ctx.notes.positiv ??
+ ctx.notes.bestnote ??
+ ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ));
const g3 = ctx.gradeNotes.find((n) => String(n) !== String(g2));
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { erstantritt: false });
+ seedBaseline(ctx, student, { erstantritt: false });
let wiederholungId;
seedPruefung(ctx, student, { note: g2, datum: attemptDate(ctx, 1), typ: "Termin2" }).then((seeded) => {
wiederholungId = seeded.pruefungId;
});
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
expect(verlaufOfStudent(data, student.uid).hatWiederholung, "hatWiederholung").to.be.true;
});
@@ -243,7 +240,7 @@ describe("Noten API - Notenvorschlag", () => {
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, g3, null, baselineDate(ctx))
.then((response) => expectNotenError(response, "c4notenvorschlagGesperrt"));
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const pruefung = attemptsOfStudent(data, student.uid).find(
(p) => String(p.pruefung_id) === String(wiederholungId),
);
@@ -255,17 +252,17 @@ describe("Noten API - Notenvorschlag", () => {
});
});
- describe("re-grading an already freigegebene note", () => {
+ 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);
});
- it("moves the state from freigegeben to changed", () => {
+ it("wechselt den Status von freigegeben auf geändert", () => {
const student = ctx.students[1];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, {
+ seedBaseline(ctx, student, {
note: ctx.gradeNotes[0],
freigegeben: true,
erstantritt: false,
@@ -273,7 +270,7 @@ describe("Noten API - Notenvorschlag", () => {
});
// baseline: benotungsdatum == freigabedatum -> freigegeben, not changed
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const grades = lvNoteOf(data, student.uid);
expect(grades.note_lv, "seeded freigegebene note is visible").to.exist;
expect(
@@ -282,18 +279,14 @@ describe("Noten API - Notenvorschlag", () => {
).to.be.false;
});
- notenApi
- .saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[1])
- .then((response) => {
- expectNotenSuccess(response, "re-grade after Freigabe");
- });
+ notenApi.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[1]).then((response) => {
+ expectNotenSuccess(response, "re-grade after Freigabe");
+ });
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const grades = lvNoteOf(data, student.uid);
- expect(String(grades.note_lv), "the new grade is stored").to.eq(
- String(ctx.gradeNotes[1]),
- );
+ expect(String(grades.note_lv), "the new grade is stored").to.eq(String(ctx.gradeNotes[1]));
expect(grades.freigabedatum, "the old Freigabe timestamp is kept").to.exist;
expect(
new Date(grades.benotungsdatum) > new Date(grades.freigabedatum),
@@ -302,11 +295,11 @@ describe("Noten API - Notenvorschlag", () => {
});
});
- it("overwrites the grade rather than adding a second row", () => {
+ it("überschreibt die Note, statt eine zweite Zeile anzulegen", () => {
const student = ctx.students[1];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, {
+ seedBaseline(ctx, student, {
note: ctx.gradeNotes[0],
freigegeben: true,
erstantritt: false,
@@ -320,5 +313,4 @@ describe("Noten API - Notenvorschlag", () => {
});
});
});
-
});
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 d1ae51f80..1e891c841 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js
@@ -14,6 +14,7 @@ import {
requireKommissionellerAntritt,
requireKonfiguration,
requireWiederholung,
+ skipWenn,
} from "../../../../support/helpers/notenConfig";
import {
attemptDate,
@@ -30,7 +31,7 @@ import {
countingAttemptsOfStudent,
editPruefung,
givenBaseline,
- readState,
+ readStateViaApi,
verlaufOfStudent,
} from "../../../../support/helpers/notenScenario";
@@ -47,10 +48,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
`entschuldigt=${ctx.notes.entschuldigt}`,
);
- expect(
- ctx.maxAntritte,
- "needs room for at least one retake beyond the first Antritt",
- ).to.be.greaterThan(1);
+ expect(ctx.maxAntritte, "needs room for at least one retake beyond the first Antritt").to.be.greaterThan(1);
});
});
@@ -58,11 +56,12 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
const studentFor = (index) => ctx.students[index % ctx.students.length];
/** Skip, wenn der letzte Antritt der Kette nicht der kommissionelle ist. */
- const skipOhneKommissionLetzt = (test) => {
- if (ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT === ctx.maxAntritte) return;
- Cypress.log({ name: "skip", message: "Übersprungen: der letzte Antritt ist nicht kommissionell." });
- test.skip();
- };
+ const skipOhneKommissionLetzt = (test) =>
+ skipWenn(
+ test,
+ ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT !== ctx.maxAntritte,
+ "Übersprungen: der letzte Antritt ist nicht kommissionell.",
+ );
/** Adds counting attempts until the cap is reached. Baseline already provides Antritt 1. */
const fillToCap = (student, firstIndex = 1) => {
@@ -80,7 +79,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §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.
- it("resolves the special notes from tbl_note by their Bezeichnung", () => {
+ it("löst die Sondernoten über ihre Bezeichnung aus tbl_note auf", () => {
notenApi.getCisConfig().then((response) => {
const config = expectNotenSuccess(response, "getCisConfig");
@@ -96,8 +95,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
});
- describe("Rule A - maximum number of Prüfungsantritte", () => {
- it("rejects an attempt once the configured maximum is reached", function () {
+ 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);
@@ -112,17 +111,19 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
// and nothing was written for the rejected attempt
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const dates = attemptsOfStudent(data, student.uid).map((p) => String(p.datum).slice(0, 10));
expect(dates, "no row may carry the rejected date").to.not.include(nextDate);
- expect(countingAttemptsOfStudent(data, student.uid), "exactly the possible counting rows")
- .to.have.length(ctx.maxAntritte);
+ expect(
+ countingAttemptsOfStudent(data, student.uid),
+ "exactly the possible counting rows",
+ ).to.have.length(ctx.maxAntritte);
});
});
});
- describe("Rule B - attempts are taken in chronological order", () => {
+ describe("Regel B - die Antritte folgen der Zeitordnung", () => {
beforeEach(function () {
requireWiederholung(this, ctx);
});
@@ -135,50 +136,46 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
};
- it("rejects a new attempt dated before an existing one", () => {
+ it("lehnt einen neuen Antritt vor einem bestehenden ab", () => {
const student = studentFor(1);
givenOpenAttemptOn(student, attemptDate(ctx, 2));
- addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) }).then(
- (response) => {
- expectNotenError(response, "pruefungDatumBeforeExisting");
- },
- );
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) }).then((response) => {
+ expectNotenError(response, "pruefungDatumBeforeExisting");
+ });
});
- it("accepts a new attempt dated after every existing one", () => {
+ it("nimmt einen neuen Antritt nach allen bestehenden an", () => {
const student = studentFor(1);
givenOpenAttemptOn(student, attemptDate(ctx, 2));
- addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 3) }).then(
- (response) => {
- expectNotenSuccess(response, "attempt strictly after the existing one");
- },
- );
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 3) }).then((response) => {
+ expectNotenSuccess(response, "attempt strictly after the existing one");
+ });
});
});
- describe("Rule C - 'entschuldigt' may be assigned only once", () => {
+ describe("Regel C - 'entschuldigt' gilt nur einmal", () => {
beforeEach(function () {
requireWiederholung(this, ctx);
});
- it("rejects a second entschuldigt attempt", () => {
+ it("lehnt einen zweiten entschuldigten Antritt ab", () => {
const student = studentFor(2);
givenBaseline(ctx, student);
- addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) }).then(
- (response) => expectNotenSuccess(response, "first entschuldigt attempt"),
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) }).then((response) =>
+ expectNotenSuccess(response, "first entschuldigt attempt"),
);
- addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 2) }).then(
- (response) => expectNotenError(response, "noteOccuranceLimitReached"),
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 2) }).then((response) =>
+ expectNotenError(response, "noteOccuranceLimitReached"),
);
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const excused = attemptsOfStudent(data, student.uid).filter(
(p) => String(p.note) === String(ctx.notes.entschuldigt),
);
@@ -187,30 +184,29 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
});
- describe("entschuldigt does not consume an attempt", () => {
+ describe("'entschuldigt' verbraucht keinen Antritt", () => {
beforeEach(function () {
requireWiederholung(this, ctx);
});
- it("still accepts a real grade after an excused attempt", () => {
+ it("nimmt nach einem entschuldigten Antritt weiter eine echte Note an", () => {
const student = studentFor(0);
givenBaseline(ctx, student);
- addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) }).then(
- (response) => expectNotenSuccess(response, "excused attempt"),
+ addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) }).then((response) =>
+ expectNotenSuccess(response, "excused attempt"),
);
// excused must not count, so this real grade is still within the cap
- addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) }).then(
- (response) =>
- expectNotenSuccess(
- response,
- "a real grade after an excused attempt (excused must not count towards the cap)",
- ),
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) }).then((response) =>
+ expectNotenSuccess(
+ response,
+ "a real grade after an excused attempt (excused must not count towards the cap)",
+ ),
);
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
expect(
verlaufOfStudent(data, student.uid).antrittCount,
"baseline + the real grade, the excused one not counted",
@@ -233,8 +229,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
// bis zum vorletzten Antritt auffüllen
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}`),
+ addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, i) }).then((response) =>
+ expectNotenSuccess(response, `Antritt ${i + 1}`),
);
}
@@ -248,7 +244,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
);
});
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const verlauf = verlaufOfStudent(data, student.uid);
expect(verlauf.antrittCount, "die kommissionelle zählt als Antritt").to.eq(ctx.maxAntritte);
expect(verlauf.canAdd, "danach ist kein Antritt mehr möglich").to.be.false;
@@ -267,12 +263,12 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
// bis zum vorletzten Antritt auffüllen; danach wäre der kommissionelle fällig
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}`),
+ addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, i) }).then((response) =>
+ expectNotenSuccess(response, `Antritt ${i + 1}`),
);
}
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const verlauf = verlaufOfStudent(data, student.uid);
expect(verlauf.kommPruefGesperrt, "der Verlauf nennt den Grund").to.be.true;
expect(verlauf.canAdd, "und lässt keinen Antritt mehr zu").to.be.false;
@@ -297,19 +293,19 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
typ: "kommPruef",
});
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const verlauf = verlaufOfStudent(data, student.uid);
expect(verlauf.terminal, "die Kette ist geschlossen").to.be.true;
expect(verlauf.canAdd, "kein weiterer Antritt möglich").to.be.false;
});
- addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) }).then(
- (response) => expectNotenError(response, "maxAntritteReached"),
+ addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) }).then((response) =>
+ expectNotenError(response, "maxAntritteReached"),
);
});
});
- describe("'Noch nicht eingetragen' does not consume an attempt", () => {
+ describe("'Noch nicht eingetragen' verbraucht keinen Antritt", () => {
beforeEach(function () {
requireWiederholung(this, ctx);
});
@@ -322,7 +318,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
(response) => expectNotenSuccess(response, "offener Termin"),
);
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const offen = attemptsOfStudent(data, student.uid).find(
(p) => String(p.note) === String(ctx.notes.nochNichtEingetragen),
);
@@ -336,20 +332,17 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
});
- describe("Anrechnung - no Prüfungen at all", () => {
- it("refuses an attempt while the Zeugnisnote is angerechnet", function () {
+ describe("Anrechnung - gar keine Prüfungen", () => {
+ it("lehnt einen Antritt bei angerechneter Zeugnisnote ab", function () {
const angerechnet = (ctx.cisConfig.NOTEN_ANRECHNUNG || [])[0];
- if (angerechnet === undefined) {
- Cypress.log({ name: "skip", message: "Übersprungen: NOTEN_ANRECHNUNG löst keine Note auf." });
- this.skip();
- }
+ skipWenn(this, angerechnet === undefined, "Übersprungen: NOTEN_ANRECHNUNG löst keine Note auf.");
const student = studentFor(4);
givenBaseline(ctx, student);
seedZeugnisnote(ctx, student.uid, angerechnet);
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const verlauf = verlaufOfStudent(data, student.uid);
expect(verlauf.angerechnet, "the Verlauf marks the Anrechnung").to.be.true;
expect(verlauf.canAdd, "and blocks further attempts").to.be.false;
@@ -361,7 +354,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
});
- describe("edit guards", () => {
+ describe("Schutz beim Bearbeiten", () => {
beforeEach(function () {
requireWiederholung(this, ctx);
});
@@ -373,7 +366,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) });
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) });
- return readState(ctx).then((data) => {
+ return readStateViaApi(ctx).then((data) => {
const excused = attemptsOfStudent(data, student.uid).find(
(p) => String(p.note) === String(ctx.notes.entschuldigt),
);
@@ -382,7 +375,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
};
- it("rejects changing the grade once a later attempt exists", function () {
+ it("lehnt eine Notenänderung nach einem späteren Antritt ab", function () {
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", true);
const student = studentFor(1);
@@ -396,7 +389,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
});
- it("still allows a date-only correction between the neighbouring attempts", () => {
+ it("erlaubt weiter eine reine Datumskorrektur zwischen den Nachbarantritten", () => {
const student = studentFor(1);
givenThreeAttempts(student).then((excused) => {
@@ -407,7 +400,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
datum: shiftDate(attemptDate(ctx, 1), 3),
})
.then((response) => expectNotenSuccess(response, "date-only correction inside the bounds"))
- .then(() => readState(ctx))
+ .then(() => readStateViaApi(ctx))
.then((data) => {
const moved = attemptsOfStudent(data, student.uid).find(
(p) => p.pruefung_id === excused.pruefung_id,
@@ -420,7 +413,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
});
- it("rejects a date on or before the previous attempt", () => {
+ it("lehnt ein Datum am oder vor dem vorherigen Antritt ab", () => {
const student = studentFor(2);
givenThreeAttempts(student).then((excused) => {
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 ec5d59717..32be13b19 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js
@@ -12,7 +12,7 @@ import {
attemptDate,
baselineDate,
loadNotenContext,
- readLvGesamtnote,
+ readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedPruefung,
@@ -23,14 +23,14 @@ import {
attemptsOfStudent,
editPruefung,
givenBaseline,
- readState,
+ readStateViaApi,
verlaufOfStudent,
} from "../../../../support/helpers/notenScenario";
import { notenApi, pruefungenOf } from "../../../../support/api/notenApi";
const dayOf = (value) => String(value).slice(0, 10);
-describe("Noten API - Prüfungstermin (write path)", () => {
+describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
let ctx;
before(() => {
@@ -43,7 +43,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
const studentFor = (index) => ctx.students[index % ctx.students.length];
- it("writes exactly one Prüfung per add - no snapshot alongside it", function () {
+ it("schreibt je Anlage genau eine Prüfung, ohne Kopie daneben", function () {
requireWiederholung(this, ctx);
const student = studentFor(0);
@@ -60,14 +60,14 @@ describe("Noten API - Prüfungstermin (write path)", () => {
expect(verlauf, "verlauf is returned").to.exist;
expect(verlauf.pruefungen, "verlauf carries the attempts").to.be.an("array");
})
- .then(() => readState(ctx))
+ .then(() => readStateViaApi(ctx))
.then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
expect(attempts, "baseline Antritt 1 plus exactly one added row").to.have.length(2);
});
});
- it("derives position and Antrittsnummer server-side", function () {
+ it("leitet Position und Antrittsnummer auf dem Server ab", function () {
requireWiederholung(this, ctx);
const student = studentFor(1);
@@ -75,26 +75,33 @@ describe("Noten API - Prüfungstermin (write path)", () => {
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) })
- .then(() => readState(ctx))
+ .then(() => readStateViaApi(ctx))
.then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
- expect(attempts.map((p) => p.position), "positions are 1..n in date order").to.deep.eq([1, 2]);
- expect(attempts.map((p) => p.antritt_nr), "both count as attempts").to.deep.eq([1, 2]);
+ expect(
+ attempts.map((p) => p.position),
+ "positions are 1..n in date order",
+ ).to.deep.eq([1, 2]);
+ expect(
+ attempts.map((p) => p.antritt_nr),
+ "both count as attempts",
+ ).to.deep.eq([1, 2]);
attempts.forEach((p) => expect(p.zaehlt, `zaehlt of position ${p.position}`).to.be.true);
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 = ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF !== false
- ? ctx.maxAntritte
- : ctx.maxAntritte - 1;
+ const anlegbar =
+ 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);
});
});
- it("appends a new row per add instead of overwriting the previous one", function () {
+ 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
@@ -109,16 +116,19 @@ describe("Noten API - Prüfungstermin (write path)", () => {
return addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 2) });
})
.then((response) => expectNotenSuccess(response, "second add"))
- .then(() => readState(ctx))
+ .then(() => readStateViaApi(ctx))
.then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
expect(attempts, "baseline + two added rows").to.have.length(3);
- expect(attempts.map((p) => p.pruefung_id), "the first add still exists").to.include(firstId);
+ expect(
+ attempts.map((p) => p.pruefung_id),
+ "the first add still exists",
+ ).to.include(firstId);
});
});
- it("edits the pruefung identified by pruefung_id", () => {
+ it("bearbeitet die Prüfung mit der übergebenen pruefung_id", () => {
// An excused and a real attempt side by side - no endpoint builds this, hence the direct seed
const student = studentFor(2);
const excusedDate = attemptDate(ctx, 1);
@@ -142,7 +152,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
}),
)
.then((response) => expectNotenSuccess(response, "moving the excused attempt"))
- .then(() => readState(ctx))
+ .then(() => readStateViaApi(ctx))
.then((data) => {
const rows = pruefungenOf(data, student.uid);
const excused = rows.find((p) => p.pruefung_id === excusedId);
@@ -150,16 +160,14 @@ describe("Noten API - Prüfungstermin (write path)", () => {
// the untargeted row first: silently rewriting a real grade is the worse outcome
expect(graded, "the other attempt still exists").to.exist;
- expect(String(graded.note), "the untargeted attempt keeps its grade").to.eq(
- String(ctx.gradeNotes[0]),
- );
+ expect(String(graded.note), "the untargeted attempt keeps its grade").to.eq(String(ctx.gradeNotes[0]));
expect(excused, "the excused attempt still exists").to.exist;
expect(dayOf(excused.datum), "the row named by pruefung_id moved").to.eq(movedTo);
});
});
- it("writes the LV note from the attempt's grade", function () {
+ it("schreibt die LV-Note aus der Note des Antritts", function () {
requireWiederholung(this, ctx);
const student = studentFor(3);
@@ -168,11 +176,9 @@ describe("Noten API - Prüfungstermin (write path)", () => {
addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) })
.then((response) => expectNotenSuccess(response, "attempt with a grade"))
- .then(() => readLvGesamtnote(ctx, student.uid))
+ .then(() => readLvGesamtnoteViaDb(ctx, student.uid))
.then((row) => {
- expect(String(row.note), "the LV note follows the newest attempt").to.eq(
- String(ctx.gradeNotes[1]),
- );
+ expect(String(row.note), "the LV note follows the newest attempt").to.eq(String(ctx.gradeNotes[1]));
});
});
@@ -188,12 +194,15 @@ describe("Noten API - Prüfungstermin (write path)", () => {
expectNotenSuccess(response, "erster Termin neben der LV-Note"),
);
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
expect(attempts, "Antritt 1 und der neue Termin").to.have.length(2);
expect(String(attempts[0].note), "Antritt 1 trägt die LV-Note").to.eq(String(ctx.notes.negativ));
expect(dayOf(attempts[0].datum) < attemptDate(ctx, 1), "Antritt 1 liegt vor dem neuen Termin").to.be.true;
- expect(attempts.map((p) => p.antritt_nr), "Antrittsnummern").to.deep.eq([1, 2]);
+ expect(
+ attempts.map((p) => p.antritt_nr),
+ "Antrittsnummern",
+ ).to.deep.eq([1, 2]);
expect(verlaufOfStudent(data, student.uid).antrittCount, "zwei Antritte").to.eq(2);
});
});
@@ -203,16 +212,18 @@ describe("Noten API - Prüfungstermin (write path)", () => {
// Die LV-Note ist nie 'entschuldigt'.
describe("LV-Note nach einem Termin", () => {
const g2 = () =>
- ctx.notes.positiv ?? ctx.notes.bestnote ?? ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ));
+ ctx.notes.positiv ??
+ ctx.notes.bestnote ??
+ ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ));
const expectLvNote = (student, note, message) =>
- readLvGesamtnote(ctx, student.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
expect(String(row.note), message).to.eq(String(note));
return row;
});
const expectAntritte = (student, anzahl) =>
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
expect(verlaufOfStudent(data, student.uid).antrittCount, "antrittCount").to.eq(anzahl);
});
@@ -226,7 +237,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
expectNotenSuccess(response, "Termin 2"),
);
- readState(ctx)
+ readStateViaApi(ctx)
.then((data) => {
const t1 = attemptsOfStudent(data, student.uid)[0];
// die Note bleibt gleich, sonst meldet validateEdit pruefungNoteLocked
@@ -269,7 +280,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
expect(row.punkte, "ohne Punkte").to.be.null;
});
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const verlauf = verlaufOfStudent(data, student.uid);
expect(verlauf.antrittCount, "der entschuldigte Termin verbraucht keinen Antritt").to.eq(0);
expect(verlauf.canAdd, "ein weiterer Termin ist möglich").to.be.true;
@@ -279,7 +290,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
expectNotenSuccess(response, "erster zählender Termin"),
);
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
const zaehlend = attemptsOfStudent(data, student.uid).find(
(p) => String(p.note) === String(ctx.notes.negativ),
);
@@ -292,7 +303,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
givenBaseline(ctx, student);
- readState(ctx)
+ readStateViaApi(ctx)
.then((data) => {
const t1 = attemptsOfStudent(data, student.uid)[0];
return editPruefung(ctx, student, {
@@ -318,7 +329,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
expectNotenSuccess(response, "Termin 2"),
);
- readState(ctx)
+ readStateViaApi(ctx)
.then((data) => {
const t2 = attemptsOfStudent(data, student.uid)[1];
return editPruefung(ctx, student, {
@@ -385,7 +396,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) }).then((response) =>
expectNotenSuccess(response, "Termin nach der Freigabe"),
);
- return readLvGesamtnote(ctx, student.uid);
+ return readLvGesamtnoteViaDb(ctx, student.uid);
};
it("hebt die Freigabe mit einem neuen Termin auf", function () {
@@ -407,7 +418,7 @@ describe("Noten API - Prüfungstermin (write path)", () => {
});
});
- it("creates the LV note when a student has none yet", () => {
+ it("legt die LV-Note an, wenn ein Studierender noch keine hat", () => {
// der Bulk-Pfad läuft durch denselben Kern und schreibt die LV-Note vor dem Termin
const student = studentFor(0);
@@ -425,13 +436,10 @@ describe("Noten API - Prüfungstermin (write path)", () => {
expect(data[student.uid], `row result for ${student.uid}`).to.be.an("object");
expect(data[student.uid].savedPruefung, "the Prüfung was written").to.exist;
})
- .then(() => readLvGesamtnote(ctx, student.uid))
+ .then(() => readLvGesamtnoteViaDb(ctx, student.uid))
.then((row) => {
expect(row, "the LV note was created alongside").to.not.be.null;
- expect(String(row.note), "carries the Prüfung's note").to.eq(
- String(ctx.notes.nochNichtEingetragen),
- );
+ expect(String(row.note), "carries the Prüfung's note").to.eq(String(ctx.notes.nochNichtEingetragen));
});
});
-
});
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 88c3bb08e..52db022b3 100644
--- a/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js
+++ b/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js
@@ -8,19 +8,29 @@
*/
import { expectBulkRowError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
-import { requireKonfiguration, requireWiederholung } from "../../../../support/helpers/notenConfig";
+import {
+ requireKonfiguration,
+ requireNotenModus,
+ requireWiederholung,
+ skipWenn,
+} from "../../../../support/helpers/notenConfig";
import {
attemptDate,
loadNotenContext,
- readLvGesamtnote,
+ readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedZeugnisnote,
} from "../../../../support/helpers/notenTestData";
-import { addPruefung, attemptsOfStudent, givenBaseline, readState } from "../../../../support/helpers/notenScenario";
+import {
+ addPruefung,
+ attemptsOfStudent,
+ givenBaseline,
+ readStateViaApi,
+} from "../../../../support/helpers/notenScenario";
import { notenApi } from "../../../../support/api/notenApi";
-describe("Noten API - Notenvorschlag overwrite rules", () => {
+describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => {
let ctx;
before(() => {
@@ -32,11 +42,10 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
const studentFor = (index) => ctx.students[index % ctx.students.length];
- it("refuses a note the editor list never offers", function () {
+ it("lehnt eine Note ab, die die Auswahlliste nicht anbietet", function () {
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN", true);
- if (ctx.notes.nichtLehre === null) {
- this.skip(); // every active note is a lehre note on this instance
- }
+ // auf dieser Instanz kann jede aktive Note eine Lehrenote sein
+ skipWenn(this, ctx.notes.nichtLehre === null, "Übersprungen: keine Verwaltungsnote in tbl_note.");
const student = studentFor(0);
@@ -46,7 +55,7 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
notenApi
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notes.nichtLehre)
.then((response) => expectNotenError(response, "c4noteNichtInLehre"))
- .then(() => readLvGesamtnote(ctx, student.uid))
+ .then(() => readLvGesamtnoteViaDb(ctx, student.uid))
.then((row) => {
expect(String(row.note), "a non-lehre note must not become the LV note").to.eq(
String(ctx.notes.negativ),
@@ -54,7 +63,7 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
});
});
- it("refuses to change the Notenvorschlag once a Prüfung exists", function () {
+ it("lehnt eine Änderung des Notenvorschlags nach einer Prüfung ab", function () {
requireWiederholung(this, ctx);
const student = studentFor(1);
@@ -69,18 +78,15 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
notenApi
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0])
.then((response) => expectNotenError(response, "c4notenvorschlagGesperrt"))
- .then(() => readLvGesamtnote(ctx, student.uid))
+ .then(() => readLvGesamtnoteViaDb(ctx, student.uid))
.then((row) => {
- expect(String(row.note), "the attempt's grade must survive the call").to.eq(
- String(ctx.gradeNotes[1]),
- );
+ expect(String(row.note), "the attempt's grade must survive the call").to.eq(String(ctx.gradeNotes[1]));
});
});
- it("refuses to overwrite a locked Zeugnisnote", function () {
- if (ctx.notes.nichtUeberschreibbar === null) {
- this.skip(); // every active note allows the teacher to overwrite it on this instance
- }
+ 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.");
const student = studentFor(4);
@@ -90,7 +96,7 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
notenApi
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[1])
.then((response) => expectNotenError(response, "c4zeugnisnoteGesperrt"))
- .then(() => readLvGesamtnote(ctx, student.uid))
+ .then(() => readLvGesamtnoteViaDb(ctx, student.uid))
.then((row) => {
expect(String(row.note), "the LV note must not change").to.eq(String(ctx.notes.negativ));
});
@@ -103,14 +109,13 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
requireKonfiguration(test, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN", true);
const ohneAntritt = (ctx.cisConfig.NOTEN_OHNE_ANTRITT || []).map(String);
- if (ctx.notes.nichtLehre !== null && !ohneAntritt.includes(String(ctx.notes.nichtLehre))) return;
+ const brauchbar = ctx.notes.nichtLehre !== null && !ohneAntritt.includes(String(ctx.notes.nichtLehre));
- Cypress.log({ name: "skip", message: "Übersprungen: keine Verwaltungsnote, die einen Antritt verbraucht." });
- test.skip();
+ skipWenn(test, !brauchbar, "Übersprungen: keine Verwaltungsnote, die einen Antritt verbraucht.");
};
const expectNurAntritt1 = (student) =>
- readState(ctx).then((data) => {
+ readStateViaApi(ctx).then((data) => {
expect(attemptsOfStudent(data, student.uid), "nur Antritt 1").to.have.length(1);
});
@@ -126,7 +131,7 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
);
expectNurAntritt1(student);
- readLvGesamtnote(ctx, student.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
expect(String(row.note), "die LV-Note bleibt").to.eq(String(ctx.notes.negativ));
});
});
@@ -139,10 +144,7 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
const gesperrt = ctx.notenOptions.find(
(n) => n.lkt_ueberschreibbar === false && !anrechnung.includes(String(n.note)),
);
- if (!gesperrt) {
- Cypress.log({ name: "skip", message: "Übersprungen: keine gesperrte Note ausser den Anrechnungsnoten." });
- this.skip();
- }
+ skipWenn(this, !gesperrt, "Übersprungen: keine gesperrte Note ausser den Anrechnungsnoten.");
const student = studentFor(1);
@@ -177,7 +179,7 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
});
students.forEach((s) =>
- readLvGesamtnote(ctx, s.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, s.uid).then((row) => {
expect(row, `keine LV-Note für ${s.uid}`).to.be.null;
}),
);
@@ -188,25 +190,20 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
// antwortet 200 und meldet die abgelehnte Zeile in data[uid].
describe("saveNotenvorschlagBulk", () => {
beforeEach(function () {
- if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
- // im Punktemodus leitet der Import die Note aus den Punkten ab und verwirft eine
- // Zeile ohne Punkte, bevor eine dieser Regeln greift
- Cypress.log({ name: "skip", message: "Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv." });
- this.skip();
- }
+ // 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);
requireWiederholung(this, ctx);
});
- it("refuses to change the Notenvorschlag once a Prüfung exists", () => {
+ it("lehnt je Zeile eine Änderung des Notenvorschlags nach einer Prüfung ab", () => {
const student = studentFor(3);
givenBaseline(ctx, student);
- addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) }).then(
- (response) => {
- expectNotenSuccess(response, "seed a Prüfung");
- },
- );
+ addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) }).then((response) => {
+ expectNotenSuccess(response, "seed a Prüfung");
+ });
notenApi
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
@@ -216,7 +213,7 @@ describe("Noten API - Notenvorschlag overwrite rules", () => {
const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
expectBulkRowError(data, student.uid, "c4notenvorschlagGesperrt");
})
- .then(() => readLvGesamtnote(ctx, student.uid))
+ .then(() => readLvGesamtnoteViaDb(ctx, student.uid))
.then((row) => {
expect(String(row.note), "the attempt's grade must survive the import").to.eq(
String(ctx.gradeNotes[1]),
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 0aca8a1ad..1f7301c59 100644
--- a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.freigabe.cy.js
+++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.freigabe.cy.js
@@ -3,7 +3,7 @@ import { requireKonfiguration } from "../../../../support/helpers/notenConfig";
import {
baselineDate,
loadNotenContext,
- readLvGesamtnote,
+ readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
@@ -37,7 +37,7 @@ context("Benotungstool UI - Notenfreigabe", () => {
const student = ctx.students[0];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
+ seedBaseline(ctx, student, { note: ctx.gradeNotes[0], freigegeben: false });
page.visitAndWaitForTable(ctx);
page.expectFreigabeState(student.uid, "changed");
@@ -53,7 +53,7 @@ context("Benotungstool UI - Notenfreigabe", () => {
const student = ctx.students[0];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
+ seedBaseline(ctx, student, { note: ctx.gradeNotes[0], freigegeben: false });
page.visitAndWaitForTable(ctx);
page.openFreigabeModal();
@@ -65,7 +65,7 @@ context("Benotungstool UI - Notenfreigabe", () => {
page.expectAbgelehnt("@saveStudentenNoten");
page.expectFreigabeState(student.uid, "changed");
- readLvGesamtnote(ctx, student.uid).then((rowData) => {
+ readLvGesamtnoteViaDb(ctx, student.uid).then((rowData) => {
expect(rowData.freigabedatum, "eine abgelehnte Freigabe stempelt nichts").to.be.null;
});
});
@@ -76,7 +76,7 @@ context("Benotungstool UI - Notenfreigabe", () => {
const student = ctx.students[0];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
+ seedBaseline(ctx, student, { note: ctx.gradeNotes[0], freigegeben: false });
page.visitAndWaitForTable(ctx);
page.expectFreigabeState(student.uid, "changed");
@@ -94,7 +94,7 @@ context("Benotungstool UI - Notenfreigabe", () => {
const student = ctx.students[1];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: false });
+ seedBaseline(ctx, student, { note: ctx.gradeNotes[0], freigegeben: false });
page.visitAndWaitForTable(ctx);
page.openFreigabeModal();
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 110b50558..84752c126 100644
--- a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.import.cy.js
+++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.import.cy.js
@@ -1,5 +1,10 @@
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
-import { requireKonfiguration, requireWiederholung } from "../../../../support/helpers/notenConfig";
+import {
+ requireKonfiguration,
+ requireNotenModus,
+ requireWiederholung,
+ skipWenn,
+} from "../../../../support/helpers/notenConfig";
import {
attemptDate,
loadNotenContext,
@@ -30,11 +35,8 @@ context("Benotungstool UI - Import", () => {
});
beforeEach(function () {
- if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
- // im Punktemodus erwarten beide Importe Punkte statt einer Note
- Cypress.log({ name: "skip", message: "Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv." });
- this.skip();
- }
+ // im Punktemodus erwarten beide Importe Punkte statt einer Note
+ requireNotenModus(this, ctx);
});
/**
@@ -45,7 +47,10 @@ context("Benotungstool UI - Import", () => {
const kuerzelNote = () => {
if (!ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL) return null;
- const kuerzelVon = (n) => String(n.anmerkung ?? "").trim().toLowerCase();
+ const kuerzelVon = (n) =>
+ String(n.anmerkung ?? "")
+ .trim()
+ .toLowerCase();
return (ctx.notenOptions ?? []).find((n) => {
const k = kuerzelVon(n);
@@ -60,10 +65,7 @@ context("Benotungstool UI - Import", () => {
describe("Notenimport", () => {
beforeEach(function () {
- if (!ctx.cisConfig.CIS_GESAMTNOTE_NOTENIMPORT) {
- Cypress.log({ name: "skip", message: "Skipped: CIS_GESAMTNOTE_NOTENIMPORT ist aus, der Button existiert nicht." });
- this.skip();
- }
+ requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
});
it("schreibt die LV-Note für jede Zeile", () => {
@@ -102,10 +104,7 @@ context("Benotungstool UI - Import", () => {
it("nimmt das Kürzel aus der Notenliste, sobald die Option das erlaubt", function () {
const kuerzel = kuerzelNote();
- if (!kuerzel) {
- Cypress.log({ name: "skip", message: "Skipped: keine Note mit brauchbarem Kürzel in tbl_note.anmerkung." });
- this.skip();
- }
+ skipWenn(this, !kuerzel, "Übersprungen: keine Note mit brauchbarem Kürzel in tbl_note.anmerkung.");
const student = ctx.students[1];
@@ -120,10 +119,7 @@ context("Benotungstool UI - Import", () => {
describe("Prüfungsimport", () => {
beforeEach(function () {
- if (!ctx.cisConfig.CIS_GESAMTNOTE_PRUEFUNGSIMPORT) {
- Cypress.log({ name: "skip", message: "Skipped: CIS_GESAMTNOTE_PRUEFUNGSIMPORT ist aus, der Button existiert nicht." });
- this.skip();
- }
+ requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
});
it("legt je Zeile einen datierten Antritt an", function () {
@@ -134,8 +130,8 @@ context("Benotungstool UI - Import", () => {
const datum = page.importDatum(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT);
resetNotenState(ctx);
- seedBaseline(ctx, a.uid, { note: ctx.notes.negativ });
- seedBaseline(ctx, b.uid, { note: ctx.notes.negativ });
+ seedBaseline(ctx, a, { note: ctx.notes.negativ });
+ seedBaseline(ctx, b, { note: ctx.notes.negativ });
page.visitAndWaitForTable(ctx);
@@ -157,11 +153,13 @@ context("Benotungstool UI - Import", () => {
resetNotenState(ctx);
page.visitAndWaitForTable(ctx);
- page.importPruefungen([[
- student.uid,
- page.importDatum(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT),
- ctx.gradeNotes[0],
- ]]);
+ page.importPruefungen([
+ [
+ student.uid,
+ page.importDatum(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT),
+ ctx.gradeNotes[0],
+ ],
+ ]);
page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 });
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
@@ -185,8 +183,8 @@ context("Benotungstool UI - Import", () => {
expect(perMatrikelnr.matrikelnr, "Matrikelnummer des Studierenden").to.be.a("string").and.not.be.empty;
resetNotenState(ctx);
- seedBaseline(ctx, perUid.uid, { note: ctx.notes.negativ });
- seedBaseline(ctx, perMatrikelnr.uid, { note: ctx.notes.negativ });
+ seedBaseline(ctx, perUid, { note: ctx.notes.negativ });
+ seedBaseline(ctx, perMatrikelnr, { note: ctx.notes.negativ });
page.visitAndWaitForTable(ctx);
page.importPruefungen([
@@ -205,7 +203,10 @@ context("Benotungstool UI - Import", () => {
page.expectWarnungen(4);
page.gesendeteUids("@savePruefungenBulk", "pruefungen").then((uids) => {
- expect(uids, "nur die gültigen Zeilen, die Matrikelnummer aufgelöst").to.deep.eq([perUid.uid, perMatrikelnr.uid]);
+ expect(uids, "nur die gültigen Zeilen, die Matrikelnummer aufgelöst").to.deep.eq([
+ perUid.uid,
+ perMatrikelnr.uid,
+ ]);
});
});
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 c2142fd01..29a7201f3 100644
--- a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.notenvorschlag.cy.js
+++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.notenvorschlag.cy.js
@@ -1,10 +1,10 @@
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
-import { requireKonfiguration } from "../../../../support/helpers/notenConfig";
+import { requireKonfiguration, requireNotenModus } from "../../../../support/helpers/notenConfig";
import {
attemptDate,
baselineDate,
loadNotenContext,
- readLvGesamtnote,
+ readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
@@ -31,11 +31,8 @@ context("Benotungstool UI - Notenvorschlag", () => {
});
beforeEach(function () {
- if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
- // im Punktemodus ist die Vorschlagsspalte gesperrt, die Note kommt aus den Punkten
- Cypress.log({ name: "skip", message: "Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv." });
- this.skip();
- }
+ // im Punktemodus ist die Vorschlagsspalte gesperrt, die Note kommt aus den Punkten
+ requireNotenModus(this, ctx);
});
it("trägt einen Vorschlag über den Zelleneditor ein", () => {
@@ -84,9 +81,10 @@ context("Benotungstool UI - Notenvorschlag", () => {
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[0]));
page.getCell(student.uid, "antritt_1").should("contain.text", page.toDDMMYYYY(datum));
- readLvGesamtnote(ctx, student.uid).then((rowData) => {
- expect(String(rowData.benotungsdatum).slice(0, 10), "das benotungsdatum bleibt heute")
- .to.eq(new Date().toISOString().slice(0, 10));
+ readLvGesamtnoteViaDb(ctx, student.uid).then((rowData) => {
+ expect(String(rowData.benotungsdatum).slice(0, 10), "das benotungsdatum bleibt heute").to.eq(
+ new Date().toISOString().slice(0, 10),
+ );
});
});
@@ -97,11 +95,13 @@ context("Benotungstool UI - Notenvorschlag", () => {
const kontrolle = 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((n) => ![vorschlag, ctx.notes.negativ].map(String).includes(String(n)));
+ const pruefungsnote = ctx.gradeNotes.find(
+ (n) => ![vorschlag, ctx.notes.negativ].map(String).includes(String(n)),
+ );
resetNotenState(ctx);
- seedBaseline(ctx, wiederholung.uid, { erstantritt: false });
- seedBaseline(ctx, kontrolle.uid);
+ seedBaseline(ctx, wiederholung, { erstantritt: false });
+ seedBaseline(ctx, kontrolle);
seedPruefung(ctx, wiederholung, { note: pruefungsnote, datum: attemptDate(ctx, 1), typ: "Termin2" });
page.visitAndWaitForTable(ctx);
@@ -123,11 +123,13 @@ context("Benotungstool UI - Notenvorschlag", () => {
page.getCell(student.uid, "note_vorschlag").click();
// exakter Vergleich: "unentschuldigt" enthält "entschuldigt"
- cy.get(".tabulator-edit-list-item").should("have.length.greaterThan", 0).then((items) => {
- const labels = [...items].map((item) => item.innerText.trim());
- expect(labels, "Optionen des Editors").to.include(bezeichnung(ctx.gradeNotes[0]));
- expect(labels, "Optionen des Editors").to.not.include(bezeichnung(ctx.notes.entschuldigt));
- });
+ cy.get(".tabulator-edit-list-item")
+ .should("have.length.greaterThan", 0)
+ .then((items) => {
+ const labels = [...items].map((item) => item.innerText.trim());
+ expect(labels, "Optionen des Editors").to.include(bezeichnung(ctx.gradeNotes[0]));
+ expect(labels, "Optionen des Editors").to.not.include(bezeichnung(ctx.notes.entschuldigt));
+ });
});
it("blendet den Übernehmen-Button aus, sobald Vorschlag und LV-Note übereinstimmen", () => {
@@ -141,5 +143,4 @@ context("Benotungstool UI - Notenvorschlag", () => {
page.getUebernehmenButton(student.uid).should("not.exist");
});
-
});
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 a091cee4d..299863d84 100644
--- a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.pruefung.cy.js
+++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.pruefung.cy.js
@@ -3,14 +3,16 @@ import { waitForOk } from "../../../../support/helpers/network";
import {
requireKommissionellerAntritt,
requireKonfiguration,
+ requireNotenModus,
requireWiederholung,
+ skipWenn,
} from "../../../../support/helpers/notenConfig";
import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import { addPruefung } from "../../../../support/helpers/notenScenario";
import {
attemptDate,
loadNotenContext,
- readLvGesamtnote,
+ readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
@@ -39,11 +41,8 @@ context("Benotungstool UI - Prüfungen", () => {
});
beforeEach(function () {
- if (ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) {
- // im Punktemodus ist das Notenfeld gesperrt, die Note kommt aus dem Notenschlüssel
- Cypress.log({ name: "skip", message: "Skipped: CIS_GESAMTNOTE_PUNKTE ist aktiv." });
- this.skip();
- }
+ // im Punktemodus ist das Notenfeld gesperrt, die Note kommt aus dem Notenschlüssel
+ requireNotenModus(this, ctx);
});
it("legt aus der Zelle eine Wiederholung an und zählt sie als Antritt 2", function () {
@@ -52,7 +51,7 @@ context("Benotungstool UI - Prüfungen", () => {
const student = ctx.students[0];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.notes.negativ });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ });
page.visitAndWaitForTable(ctx);
@@ -68,7 +67,7 @@ context("Benotungstool UI - Prüfungen", () => {
page.expectLvNote(student.uid, bezeichnung(ctx.gradeNotes[1]));
// die Zelle zeigt den Freigabestatus, den der Server geschrieben hat; die Regel prüft noten.pruefungstermin
- readLvGesamtnote(ctx, student.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
const geaendert = new Date(row.benotungsdatum) > new Date(row.freigabedatum);
page.expectFreigabeState(student.uid, geaendert ? "changed" : "ok");
});
@@ -99,7 +98,7 @@ context("Benotungstool UI - Prüfungen", () => {
const neuesDatum = attemptDate(ctx, 2);
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.notes.negativ });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ });
page.visitAndWaitForTable(ctx);
@@ -120,7 +119,7 @@ context("Benotungstool UI - Prüfungen", () => {
const student = ctx.students[0];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.notes.negativ });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ });
page.visitAndWaitForTable(ctx);
@@ -147,8 +146,8 @@ context("Benotungstool UI - Prüfungen", () => {
const [a, b] = ctx.students;
resetNotenState(ctx);
- seedBaseline(ctx, a.uid, { note: ctx.notes.negativ });
- seedBaseline(ctx, b.uid, { note: ctx.notes.negativ });
+ seedBaseline(ctx, a, { note: ctx.notes.negativ });
+ seedBaseline(ctx, b, { note: ctx.notes.negativ });
page.visitAndWaitForTable(ctx);
@@ -175,7 +174,7 @@ context("Benotungstool UI - Prüfungen", () => {
// 'entschuldigt' verbraucht keinen Antritt, deshalb bleibt ein weiterer Termin möglich
const mitTerminAm = (student, datum) => {
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.notes.negativ });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ });
addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum }).then((response) =>
expectNotenSuccess(response, "bestehender Termin"),
);
@@ -215,15 +214,16 @@ context("Benotungstool UI - Prüfungen", () => {
it("legt den letzten Antritt als kommissionelle Prüfung an", function () {
requireKommissionellerAntritt(this, ctx);
- if (ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT !== ctx.maxAntritte) {
- Cypress.log({ name: "skip", message: "Übersprungen: der letzte Antritt ist nicht kommissionell." });
- this.skip();
- }
+ skipWenn(
+ this,
+ ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT !== ctx.maxAntritte,
+ "Übersprungen: der letzte Antritt ist nicht kommissionell.",
+ );
const student = ctx.students[1];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.notes.negativ });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ });
page.visitAndWaitForTable(ctx);
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 ee12dc61d..3db49cbc1 100644
--- a/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.punkte.cy.js
+++ b/tests/cypress/e2e/specs/ui/benotungstool/benotungstool.punkte.cy.js
@@ -1,14 +1,10 @@
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
-import {
- requireKonfiguration,
- requirePunkteModus,
- requireWiederholung,
-} from "../../../../support/helpers/notenConfig";
-import { attemptsOfStudent, readState } from "../../../../support/helpers/notenScenario";
+import { requireKonfiguration, requirePunkteModus, requireWiederholung } from "../../../../support/helpers/notenConfig";
+import { attemptsOfStudent, readStateViaApi } from "../../../../support/helpers/notenScenario";
import {
attemptDate,
loadNotenContext,
- readLvGesamtnote,
+ readLvGesamtnoteViaDb,
requireDbReset,
resetNotenState,
seedBaseline,
@@ -82,7 +78,7 @@ context("Benotungstool UI - Punktemodus", () => {
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteMitte));
page.expectFreigabeState(student.uid, "changed");
- readLvGesamtnote(ctx, student.uid).then((row) => {
+ 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);
});
@@ -94,7 +90,7 @@ context("Benotungstool UI - Punktemodus", () => {
const student = ctx.students[1];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.gradeNotes[0], freigegeben: true });
+ seedBaseline(ctx, student, { note: ctx.gradeNotes[0], freigegeben: true });
seedPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, 1),
@@ -113,7 +109,7 @@ context("Benotungstool UI - Punktemodus", () => {
const student = ctx.students[1];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.notes.negativ, freigegeben: true });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: true });
page.visitAndWaitForTable(ctx);
page.getPruefungAddButton(student.uid, "antritt_2").click();
@@ -127,7 +123,7 @@ context("Benotungstool UI - Punktemodus", () => {
const student = ctx.students[1];
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.notes.negativ, freigegeben: true });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: true });
page.visitAndWaitForTable(ctx);
page.getPruefungAddButton(student.uid, "antritt_2").click();
@@ -153,8 +149,8 @@ context("Benotungstool UI - Punktemodus", () => {
const [a, b] = [ctx.students[2], ctx.students[3]];
resetNotenState(ctx);
- seedBaseline(ctx, a.uid, { note: ctx.notes.negativ, freigegeben: true });
- seedBaseline(ctx, b.uid, { note: ctx.notes.negativ, freigegeben: true });
+ seedBaseline(ctx, a, { note: ctx.notes.negativ, freigegeben: true });
+ 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)) });
@@ -179,14 +175,14 @@ context("Benotungstool UI - Punktemodus", () => {
const datum = page.importDatum(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT);
resetNotenState(ctx);
- seedBaseline(ctx, student.uid, { note: ctx.notes.negativ, freigegeben: true });
+ seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: true });
page.visitAndWaitForTable(ctx);
page.importPruefungen([[student.uid, datum, eingabe]]);
cy.get("@savePruefungenBulk").its("request.body.pruefungen.0.punkte").should("eq", PUNKTE);
- readState(ctx).then((data) => {
+ 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);
@@ -205,7 +201,7 @@ context("Benotungstool UI - Punktemodus", () => {
cy.get("@saveNotenvorschlagBulk").its("request.body.noten.0.punkte").should("eq", PUNKTE);
- readLvGesamtnote(ctx, student.uid).then((row) => {
+ readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
expect(row, "die importierte LV-Note").to.not.be.null;
expect(Number(row.punkte), "die Punkte der LV-Note").to.eq(PUNKTE);
});
diff --git a/tests/cypress/e2e/specs/unit/notenRules.cy.js b/tests/cypress/e2e/specs/unit/notenRules.cy.js
index 49a52b7fe..f3f62573f 100644
--- a/tests/cypress/e2e/specs/unit/notenRules.cy.js
+++ b/tests/cypress/e2e/specs/unit/notenRules.cy.js
@@ -39,18 +39,48 @@ const student = (props) => ({ pruefungen: [], note: null, hoechsterAntritt: 0, .
/** A row whose Verlauf the server has already delivered. */
const withVerlauf = (verlauf, props = {}) => student({ verlauf: { maxAntritte: 2, ...verlauf }, ...props });
-describe("Benotungstool rules (client)", () => {
+describe("Benotungstool-Regeln (Client)", () => {
describe("maxAntrittCount", () => {
+ it("liest die Zahl aus der Konfiguration", () => {
+ expect(maxAntrittCount(configWith(3))).to.eq(3);
+ expect(maxAntrittCount(configWith(1))).to.eq(1);
+ });
+
+ it("nimmt 1, solange die Konfiguration keine Zahl liefert", () => {
+ // getCisConfig liefert den Schlüssel erst nach dem Laden; bis dahin gilt ein Antritt
+ 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.
+ 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.
+ 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);
+ });
+
+ it("nimmt 1, wenn der Wert keine Zahl ist", () => {
+ expect(maxAntrittCount(configWith("abc")), "unbrauchbarer Text").to.eq(1);
+ expect(maxAntrittCount(configWith("")), "leerer Text").to.eq(1);
+ });
});
describe("antrittCountStudent", () => {
- it("takes the count from the Verlauf when the server delivered one", () => {
+ it("nimmt die Anzahl aus dem Verlauf, wenn der Server einen liefert", () => {
const s = withVerlauf({ antrittCount: 2 }, { note: 1, pruefungen: [{ note: 5 }] });
expect(antrittCountStudent(s, ours, notenOptions), "server wins over the local fallback").to.eq(2);
});
- describe("local fallback (Verlauf not loaded yet)", () => {
- it("does not count entschuldigt, 'noch nicht eingetragen' or 'nicht beurteilt'", () => {
+ describe("lokaler Rückfall, solange der Verlauf fehlt", () => {
+ it("zählt entschuldigt, 'noch nicht eingetragen' und 'nicht beurteilt' nicht mit", () => {
// still just the original note: none of these consumed an attempt, which is what lets a
// second retake be entered
const s = student({
@@ -60,31 +90,34 @@ describe("Benotungstool rules (client)", () => {
expect(antrittCountStudent(s, ours, notenOptions)).to.eq(1);
});
- it("counts the original LV note as the first Antritt", () => {
+ it("zählt die ursprüngliche LV-Note als ersten Antritt", () => {
expect(antrittCountStudent(student({ note: 1 }), ours, notenOptions)).to.eq(1);
});
+ // dieselbe Falle wie in brauchtNeueLvNote: die Note 0 ist eine Note, kein fehlender Wert
+ 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);
+ });
});
});
describe("canAddPruefung", () => {
- it("follows the Verlauf", () => {
+ it("folgt dem Verlauf", () => {
expect(canAddPruefung(withVerlauf({ canAdd: true, antrittCount: 1 }), ours)).to.be.true;
expect(canAddPruefung(withVerlauf({ canAdd: false, antrittCount: 2 }), ours)).to.be.false;
});
- it("blocks an angerechnet row even though attempts are left", () => {
+ it("sperrt eine angerechnete Zeile trotz freier Antritte", () => {
// 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;
});
- it("falls back to the cap and the terminal attempt without a Verlauf", () => {
+ it("fällt ohne Verlauf auf das Maximum und den letzten Antritt zurück", () => {
expect(canAddPruefung(student({ note: 1 }), ours), "one attempt of two used").to.be.true;
- expect(
- canAddPruefung(student({ note: 1, pruefungen: [{ note: 5 }, { note: 4 }] }), ours),
- "cap reached",
- ).to.be.false;
+ expect(canAddPruefung(student({ note: 1, pruefungen: [{ note: 5 }, { note: 4 }] }), ours), "cap reached").to
+ .be.false;
expect(
canAddPruefung(student({ note: 1, pruefungen: [{ note: 5, kommissionell: true }] }), ours),
"terminal attempt exists",
@@ -93,7 +126,7 @@ describe("Benotungstool rules (client)", () => {
// 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.
- it("stops counting the LV note once a Prüfung exists", () => {
+ 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);
expect(canAddPruefung(s, ours), "so another attempt still looks available").to.be.true;
@@ -101,10 +134,50 @@ describe("Benotungstool rules (client)", () => {
});
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".
+ */
+ it("meldet keinen Bedarf, wenn die LV-Note nur noch nicht freigegeben ist", () => {
+ const s = { verlauf: { hatLvNote: true }, lv_note: null };
+ expect(brauchtNeueLvNote(s), "die Note existiert, sie ist nur nicht freigegeben").to.be.false;
+ });
+
+ it("meldet Bedarf, wenn der Verlauf keine LV-Note kennt", () => {
+ // auch wenn lv_note gesetzt ist: der Verlauf entscheidet
+ 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".
+ it("meldet Bedarf, wenn der Verlauf das Feld nicht trägt", () => {
+ expect(brauchtNeueLvNote({ verlauf: {} })).to.be.true;
+ });
+
+ describe("lokaler Rückfall, solange der Verlauf fehlt", () => {
+ it("liest lv_note", () => {
+ expect(brauchtNeueLvNote({ lv_note: 1 }), "eine Note ist da").to.be.false;
+ expect(brauchtNeueLvNote({ lv_note: null }), "keine Note").to.be.true;
+ 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.
+ it("erkennt die Note 0 als Note", () => {
+ expect(brauchtNeueLvNote({ lv_note: 0 })).to.be.false;
+ });
+
+ it("hält einen leeren Text für keine Note", () => {
+ expect(brauchtNeueLvNote({ lv_note: "" })).to.be.true;
+ });
+ });
});
describe("checkFreigabe", () => {
- it("derives the state from the two timestamps", () => {
+ it("leitet den Status aus den beiden Zeitstempeln ab", () => {
expect(checkFreigabe(null, null), "no grade at all").to.eq("offen");
expect(checkFreigabe(null, "2026-01-10 08:00:00"), "never released").to.eq("changed");
expect(checkFreigabe("2026-01-10 08:00:00", "2026-01-20 08:00:00"), "re-graded").to.eq("changed");
diff --git a/tests/cypress/profiles/noten.js b/tests/cypress/profiles/noten.js
index f92b69fbd..29b7d45aa 100644
--- a/tests/cypress/profiles/noten.js
+++ b/tests/cypress/profiles/noten.js
@@ -106,7 +106,8 @@ module.exports = {
},
offen: {
- beschreibung: "frühere Noten änderbar, künftiges Benotungsdatum, keine Datumsfrist, ein Termin hebt die Freigabe nicht auf",
+ beschreibung:
+ "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,
CIS_GESAMTNOTE_DATUM_ZUKUNFT: true,
diff --git a/tests/cypress/suites/noten.js b/tests/cypress/suites/noten.js
index aaee5df80..85eec77b8 100644
--- a/tests/cypress/suites/noten.js
+++ b/tests/cypress/suites/noten.js
@@ -16,20 +16,22 @@ const parallelPost = ({ path, bodies }) => {
const base = String(process.env.BASE_URL).replace(/\/+$/, "");
const auth = "Basic " + Buffer.from(`${process.env.USER_NAME}:${process.env.USER_PASSWORD}`).toString("base64");
- return Promise.all(bodies.map((body) =>
- fetch(`${base}/index.ci.php/api/frontend/v1/Noten/${path}`, {
- method: "POST",
- headers: { Authorization: auth, "Content-Type": "application/json" },
- body: JSON.stringify(body),
- }).then(async (r) => {
- const text = await r.text();
- try {
- return { status: r.status, body: JSON.parse(text) };
- } catch (e) {
- return { status: r.status, body: null, text: text.slice(0, 500) };
- }
- }),
- ));
+ return Promise.all(
+ bodies.map((body) =>
+ fetch(`${base}/index.ci.php/api/frontend/v1/Noten/${path}`, {
+ method: "POST",
+ headers: { Authorization: auth, "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ }).then(async (r) => {
+ const text = await r.text();
+ try {
+ return { status: r.status, body: JSON.parse(text) };
+ } catch (e) {
+ return { status: r.status, body: null, text: text.slice(0, 500) };
+ }
+ }),
+ ),
+ );
};
module.exports = {
diff --git a/tests/cypress/support/api/notenApi.js b/tests/cypress/support/api/notenApi.js
index 79d304cd2..2a29e41f6 100644
--- a/tests/cypress/support/api/notenApi.js
+++ b/tests/cypress/support/api/notenApi.js
@@ -26,11 +26,9 @@ export const notenApi = {
getNoten: () => apiGet("getNoten"),
- getBenotungstoolContext: (sem_kurzbz, lv_id = null) =>
- apiGet("getBenotungstoolContext", { sem_kurzbz, lv_id }),
+ getBenotungstoolContext: (sem_kurzbz, lv_id = null) => apiGet("getBenotungstoolContext", { sem_kurzbz, lv_id }),
- getLvForStudiengang: (studiengang_kz, sem_kurzbz) =>
- apiGet("getLvForStudiengang", { studiengang_kz, sem_kurzbz }),
+ getLvForStudiengang: (studiengang_kz, sem_kurzbz) => apiGet("getLvForStudiengang", { studiengang_kz, sem_kurzbz }),
getLehrendeFuerLehreinheit: (lehreinheit_id, lv_id, sem_kurzbz) =>
apiGet("getLehrendeFuerLehreinheit", { lehreinheit_id, lv_id, sem_kurzbz }),
@@ -50,10 +48,26 @@ export const notenApi = {
/** data -> [savedPruefung, lvgesamtnote, verlauf]. Kein `typ` auf der Leitung. Ohne mitarbeiter_uid fehlt das Feld. */
saveStudentPruefung: ({
- student_uid, note, punkte = null, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null, mitarbeiter_uid,
+ student_uid,
+ note,
+ punkte = null,
+ datum,
+ lva_id,
+ lehreinheit_id,
+ sem_kurzbz,
+ pruefung_id = null,
+ mitarbeiter_uid,
}) =>
apiPost("saveStudentPruefung", {
- student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id, mitarbeiter_uid,
+ student_uid,
+ note,
+ punkte,
+ datum,
+ lva_id,
+ lehreinheit_id,
+ sem_kurzbz,
+ pruefung_id,
+ mitarbeiter_uid,
}),
/**
@@ -66,8 +80,7 @@ export const notenApi = {
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 }),
+ 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]
saveNotenvorschlagBulk: (lv_id, sem_kurzbz, noten) =>
@@ -83,8 +96,7 @@ export const notenApi = {
// --- selectors over the getStudentenNoten payload ---
-export const pruefungenOf = (data, uid) =>
- (data[1] || []).filter((p) => p.student_uid === uid);
+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. */
export const attemptsOf = (data, uid) =>
@@ -94,8 +106,7 @@ export const attemptsOf = (data, uid) =>
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 pruefungenOfTyp = (data, uid, typ) => pruefungenOf(data, uid).filter((p) => p.pruefungstyp_kurzbz === typ);
export const gradesOf = (data, uid) => (data[3] || {})[uid];
diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js
index 35423667a..e3fa720da 100644
--- a/tests/cypress/support/commands.js
+++ b/tests/cypress/support/commands.js
@@ -2,26 +2,26 @@
/** Visits a page and fails if any request returned >= 400. */
Cypress.Commands.add("checkPageResources", (url) => {
- const failedRequests = [];
+ const failedRequests = [];
- cy.intercept("**", (req) => {
- req.on("response", (res) => {
- if (res.statusCode >= 400) {
- failedRequests.push({ url: req.url, status: res.statusCode });
- }
- });
- });
+ cy.intercept("**", (req) => {
+ req.on("response", (res) => {
+ if (res.statusCode >= 400) {
+ failedRequests.push({ url: req.url, status: res.statusCode });
+ }
+ });
+ });
- cy.env(["adminusername", "adminpassword"]).then(({ adminusername, adminpassword }) => {
- cy.visit(url, { auth: { username: adminusername, password: adminpassword } });
- });
+ cy.env(["adminusername", "adminpassword"]).then(({ adminusername, adminpassword }) => {
+ cy.visit(url, { auth: { username: adminusername, password: adminpassword } });
+ });
- cy.then(() => {
- if (failedRequests.length > 0) {
- const message = failedRequests.map((r) => `${r.status} - ${r.url}`).join("\n");
- throw new Error(`Broken resources detected:\n${message}`);
- }
- });
+ cy.then(() => {
+ if (failedRequests.length > 0) {
+ const message = failedRequests.map((r) => `${r.status} - ${r.url}`).join("\n");
+ throw new Error(`Broken resources detected:\n${message}`);
+ }
+ });
});
/**
@@ -30,22 +30,19 @@ Cypress.Commands.add("checkPageResources", (url) => {
* Ohne Argumente meldet sich der Suite-Benutzer an.
*/
Cypress.Commands.add("login", (username, password) => {
- const { adminusername, adminpassword } = Cypress.env();
- const auth = { username: username || adminusername, password: password || adminpassword };
- const probe = () =>
- cy
- .request({ url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID", auth })
- .its("status")
- .should("eq", 200);
+ const { adminusername, adminpassword } = Cypress.env();
+ const auth = { username: username || adminusername, password: password || adminpassword };
+ 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, {
- cacheAcrossSpecs: true,
- validate: probe,
- });
+ cy.session(["benotungstool-login", auth.username], probe, {
+ cacheAcrossSpecs: true,
+ validate: probe,
+ });
});
// Anwendungsfehler sollen den Test nicht abbrechen, aber sichtbar sein.
Cypress.on("uncaught:exception", (err) => {
- Cypress.log({ name: "app error", message: err.message, consoleProps: () => ({ error: err }) });
- return false;
+ Cypress.log({ name: "app error", message: err.message, consoleProps: () => ({ error: err }) });
+ return false;
});
diff --git a/tests/cypress/support/helpers/network.js b/tests/cypress/support/helpers/network.js
index 7a9a7b2cb..6e95ada6a 100644
--- a/tests/cypress/support/helpers/network.js
+++ b/tests/cypress/support/helpers/network.js
@@ -1,2 +1 @@
-export const waitForOk = (alias) =>
- cy.wait(alias).its("response.statusCode").should("eq", 200);
+export const waitForOk = (alias) => cy.wait(alias).its("response.statusCode").should("eq", 200);
diff --git a/tests/cypress/support/helpers/notenAssistenz.js b/tests/cypress/support/helpers/notenAssistenz.js
index d206eb0d8..7d9b5fb35 100644
--- a/tests/cypress/support/helpers/notenAssistenz.js
+++ b/tests/cypress/support/helpers/notenAssistenz.js
@@ -7,6 +7,7 @@
*/
import { expectNotenSuccess } from "./notenErrors";
+import { skipWenn } from "./notenConfig";
const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten";
const SEMESTER_SUCHTIEFE = 6;
@@ -14,6 +15,14 @@ const SEMESTER_SUCHTIEFE = 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(
+ testContext,
+ !assistenzConfigured(),
+ "Übersprungen: NOTEN_ASSISTENZ_USER / NOTEN_ASSISTENZ_PASSWORD fehlen.",
+ );
+
export const assistenzAuth = () => ({
username: Cypress.env("NOTEN_ASSISTENZ_USER"),
password: Cypress.env("NOTEN_ASSISTENZ_PASSWORD"),
@@ -48,9 +57,9 @@ export const assistenzKontext = (startSem) => {
i >= liste.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);
- });
+ const data = expectNotenSuccess(response, `Kontext der Assistenz in ${liste[i]}`);
+ return data.studiengaenge.length ? { sem: liste[i], data } : probiere(i + 1);
+ });
return probiere(0);
};
@@ -67,19 +76,24 @@ export const assistenzLvMitLehreinheiten = (startSem) =>
j >= lvs.length
? cy.wrap(null, { log: false })
: cy
- .task("noten:db:lehreinheitenDerLv", { 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),
- );
+ .task("noten:db:lehreinheitenDerLv", { 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),
+ );
const probiereStudiengang = (i) =>
i >= studiengaenge.length
? cy.wrap(null, { log: false })
- : getAlsAssistenz("getLvForStudiengang", { studiengang_kz: studiengaenge[i].studiengang_kz, sem_kurzbz: sem })
- .then((response) => ersteLvMitLehreinheiten(expectNotenSuccess(response, "LVs des Studiengangs"), 0))
- .then((ziel) => ziel || probiereStudiengang(i + 1));
+ : getAlsAssistenz("getLvForStudiengang", {
+ studiengang_kz: studiengaenge[i].studiengang_kz,
+ sem_kurzbz: sem,
+ })
+ .then((response) =>
+ ersteLvMitLehreinheiten(expectNotenSuccess(response, "LVs des Studiengangs"), 0),
+ )
+ .then((ziel) => ziel || probiereStudiengang(i + 1));
return probiereStudiengang(0);
});
diff --git a/tests/cypress/support/helpers/notenConfig.js b/tests/cypress/support/helpers/notenConfig.js
index d9b108184..5fcf2a1de 100644
--- a/tests/cypress/support/helpers/notenConfig.js
+++ b/tests/cypress/support/helpers/notenConfig.js
@@ -10,6 +10,19 @@
* vor - passt die Instanz nicht dazu, scheitert der Lauf sofort und laut.
*/
+/**
+ * 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.
+ *
+ * Die require*-Helfer unten nennen je eine fachliche Voraussetzung und rufen ihrerseits skipWenn.
+ */
+export const skipWenn = (testContext, bedingung, grund) => {
+ if (!bedingung) return;
+ Cypress.log({ name: "skip", message: grund });
+ testContext.skip();
+};
+
/** Ist der Punktemodus auf dieser Instanz aktiv? */
export const punkteModus = (ctx) => Boolean(ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE);
@@ -35,24 +48,19 @@ export const assertPunkteModus = (ctx) => {
};
/** Skip, wenn der Punktemodus aus ist. Als erste Zeile in beforeEach(function(){...}) aufrufen. */
-export const requirePunkteModus = (testContext, ctx) => {
- if (punkteModus(ctx)) return;
- cy.log("Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aus.");
- testContext.skip();
-};
+export const requirePunkteModus = (testContext, ctx) =>
+ skipWenn(testContext, !punkteModus(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) => {
- if (ctx.cisConfig[key] === wert) return;
- Cypress.log({
- name: "skip",
- message: `Übersprungen: ${key} ist ${JSON.stringify(ctx.cisConfig[key])}, der Test braucht ${JSON.stringify(wert)}.`,
- });
- testContext.skip();
-};
+export const requireKonfiguration = (testContext, ctx, key, wert) =>
+ skipWenn(
+ testContext,
+ ctx.cisConfig[key] !== wert,
+ `Übersprungen: ${key} ist ${JSON.stringify(ctx.cisConfig[key])}, der Test braucht ${JSON.stringify(wert)}.`,
+ );
/**
* Skip, wenn das Werkzeug keinen zweiten Antritt anlegt. Ohne CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF
@@ -64,9 +72,7 @@ export const requireWiederholung = (testContext, ctx) => {
ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF === false && ab !== null
? Math.min(ctx.maxAntritte, ab - 1)
: ctx.maxAntritte;
- if (anlegbar >= 2) return;
- Cypress.log({ name: "skip", message: `Übersprungen: das Werkzeug legt nur ${anlegbar} Antritt an.` });
- testContext.skip();
+ skipWenn(testContext, anlegbar < 2, `Übersprungen: das Werkzeug legt nur ${anlegbar} Antritt an.`);
};
/**
@@ -76,17 +82,14 @@ export const requireWiederholung = (testContext, ctx) => {
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;
- if (anlegbar && ab !== null && ab >= 2 && ab <= ctx.maxAntritte) return;
- Cypress.log({
- name: "skip",
- message: `Übersprungen: kein anlegbarer kommissioneller Antritt (ab ${JSON.stringify(ab)}, anlegen ${anlegbar}).`,
- });
- testContext.skip();
+ const moeglich = anlegbar && ab !== null && ab >= 2 && ab <= ctx.maxAntritte;
+ skipWenn(
+ testContext,
+ !moeglich,
+ `Übersprungen: kein anlegbarer kommissioneller Antritt (ab ${JSON.stringify(ab)}, anlegen ${anlegbar}).`,
+ );
};
/** Skip, wenn der Punktemodus an ist. */
-export const requireNotenModus = (testContext, ctx) => {
- if (!punkteModus(ctx)) return;
- cy.log("Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
- testContext.skip();
-};
+export const requireNotenModus = (testContext, ctx) =>
+ skipWenn(testContext, punkteModus(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
diff --git a/tests/cypress/support/helpers/notenErrors.js b/tests/cypress/support/helpers/notenErrors.js
index 37f3ac96a..d86f0ed1a 100644
--- a/tests/cypress/support/helpers/notenErrors.js
+++ b/tests/cypress/support/helpers/notenErrors.js
@@ -116,7 +116,12 @@ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Split on the {n} placeholders first, then escape - the other order breaks on the braces.
const toPattern = (text) =>
- new RegExp(text.split(/\{\d+\}/).map(escapeRegExp).join("[\\s\\S]*?"));
+ new RegExp(
+ text
+ .split(/\{\d+\}/)
+ .map(escapeRegExp)
+ .join("[\\s\\S]*?"),
+ );
const patternsFor = (key) => {
const phrase = PHRASES[key];
@@ -141,7 +146,7 @@ export const expectNotenError = (response, key) => {
export const expectNotenSuccess = (response, context = "request") => {
// carry the server's message into the assertion, or a 500 says only "expected 200"
- const errors = (response.body && response.body.errors || []).map((e) => e.message).join(" | ");
+ const errors = ((response.body && response.body.errors) || []).map((e) => e.message).join(" | ");
expect(response.status, `HTTP status for ${context}${errors ? ` -- ${errors}` : ""}`).to.eq(200);
expect(response.body, context).to.have.nested.property("meta.status", "success");
return response.body.data;
@@ -164,7 +169,6 @@ export const expectBulkRowError = (data, uid, key) => {
export const expectBulkRowAccepted = (data, uid) => {
expect(data).to.have.property(uid);
const value = data[uid];
- const isError =
- typeof value === "string" && Object.keys(PHRASES).some((k) => messageMatchesPhrase(value, k));
+ const isError = typeof value === "string" && Object.keys(PHRASES).some((k) => messageMatchesPhrase(value, k));
expect(isError, `expected row "${uid}" accepted, got: ${JSON.stringify(value)}`).to.be.false;
};
diff --git a/tests/cypress/support/helpers/notenScenario.js b/tests/cypress/support/helpers/notenScenario.js
index 67e3acb2a..8a71f6200 100644
--- a/tests/cypress/support/helpers/notenScenario.js
+++ b/tests/cypress/support/helpers/notenScenario.js
@@ -35,7 +35,11 @@ export const editPruefung = (context, student, { pruefungId, note, datum }) =>
pruefung_id: pruefungId,
});
-export const readState = (context) =>
+/**
+ * Liest den Zustand über die API (getStudentenNoten): was der Server MELDET.
+ * Das Gegenstück ist readLvGesamtnoteViaDb - es liest, was wirklich gespeichert ist.
+ */
+export const readStateViaApi = (context) =>
notenApi
.getStudentenNoten(context.lvId, context.semKurzbz)
.then((response) => expectNotenSuccess(response, "getStudentenNoten"));
@@ -53,4 +57,4 @@ export const lvNoteOf = (data, uid) => gradesOf(data, uid);
/** Clears the suite's rows and re-seeds the Antritt-1 baseline for one student. */
export const givenBaseline = (context, student, options = {}) =>
- resetNotenState(context).then(() => seedBaseline(context, student.uid, options));
+ resetNotenState(context).then(() => seedBaseline(context, student, options));
diff --git a/tests/cypress/support/helpers/notenTestData.js b/tests/cypress/support/helpers/notenTestData.js
index 7f3e955a6..dbfeac156 100644
--- a/tests/cypress/support/helpers/notenTestData.js
+++ b/tests/cypress/support/helpers/notenTestData.js
@@ -10,8 +10,13 @@ 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";
import {
- describeFailure, performRead, performReset, performSeed, performSeedPruefung,
- performSeedZeugnisnote, resolveResetStrategy,
+ describeFailure,
+ performRead,
+ performReset,
+ performSeed,
+ performSeedPruefung,
+ performSeedZeugnisnote,
+ resolveResetStrategy,
} from "./notenReset";
import { assertPunkteModus } from "./notenConfig";
@@ -24,8 +29,7 @@ let cachedContext = null;
export const pad2 = (n) => String(n).padStart(2, "0");
-export const toDateString = (date) =>
- `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
+export const toDateString = (date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
export const shiftDate = (dateString, days) => {
const [y, m, d] = dateString.split("-").map(Number);
@@ -35,11 +39,7 @@ export const shiftDate = (dateString, days) => {
};
/** Deadline the server derives (computeNoteneintragungsfrist): SS -> 15.11.yyyy, WS -> 15.05.yyyy+1. */
-export const expectedFristString = (
- semKurzbz,
- ssConfig = { month: 11, day: 15 },
- wsConfig = { month: 5, day: 15 },
-) => {
+export const expectedFristString = (semKurzbz, ssConfig = { month: 11, day: 15 }, wsConfig = { month: 5, day: 15 }) => {
const type = semKurzbz.slice(0, 2).toUpperCase();
const year = Number(semKurzbz.slice(2, 6));
const cfg = type === "SS" ? ssConfig : wsConfig;
@@ -60,7 +60,7 @@ export const fristHasPassed = (semKurzbz, ssConfig = { month: 11, day: 15 }, wsC
* A semester + course where the logged-in user actually teaches AND the grade deadline has passed.
*
* Both are needed: assertLvAccess runs BEFORE the deadline check, so a semester the user does not
- * teach in fails there and never reaches the deadline. Seeder 019 provides the Sommersemester.
+ * teach in fails there and never reaches the deadline. Seeder group benotungstool_fixture_erweitert provides the Sommersemester.
*
* @param {string} type "SS" or "WS"
* @returns {Cypress.Chainable<{semKurzbz: string, lvId: number}|null>}
@@ -137,7 +137,6 @@ const resolveLehrveranstaltung = (semKurzbz) => {
});
};
-
/** -> { semKurzbz, lvId, cisConfig, maxAntritte, notes, gradeNotes, students, studentUids } */
export const loadNotenContext = () => {
if (cachedContext) return cy.wrap(cachedContext, { log: false });
@@ -183,7 +182,10 @@ export const loadNotenContext = () => {
context.gradeNotes =
ordinary.length > 1
? ordinary
- : usable.map((n) => n.note).filter((n) => Number(n) !== 0).sort((a, b) => Number(a) - Number(b));
+ : usable
+ .map((n) => n.note)
+ .filter((n) => Number(n) !== 0)
+ .sort((a, b) => Number(a) - Number(b));
expect(context.gradeNotes.length, "need two ordinary grades to vary one on edit").to.be.greaterThan(1);
@@ -244,8 +246,7 @@ export const loadNotenContext = () => {
// --- fixture state ---
-export const resetNotenState = (context, studentUids) =>
- performReset(context, studentUids || context.studentUids);
+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
@@ -254,18 +255,24 @@ export const resetNotenState = (context, studentUids) =>
*
* `erstantritt: false` (bzw. `freigegeben: false`) seedet die Altdaten-Form ohne diese Zeile.
*/
-export const seedBaseline = (context, studentUid, options = {}) => {
+export const seedBaseline = (context, student, options = {}) => {
+ expect(student, "seedBaseline braucht das Studierenden-Objekt aus context.students, nicht die uid").to.be.an(
+ "object",
+ );
+
// 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.
const freigegeben =
- options.freigegeben !== undefined ? options.freigegeben : context.cisConfig.CIS_GESAMTNOTE_FREIGABE_FINAL !== true;
+ options.freigegeben !== undefined
+ ? options.freigegeben
+ : context.cisConfig.CIS_GESAMTNOTE_FREIGABE_FINAL !== true;
// Antritt 1 fehlt nur bei ausdrücklichem erstantritt: false oder freigegeben: false
const erstantritt = options.erstantritt !== undefined ? options.erstantritt : options.freigegeben !== false;
- return performSeed(context, studentUid, {
+ return performSeed(context, student.uid, {
note,
punkte: options.punkte !== undefined ? options.punkte : null,
benotungsdatum: options.benotungsdatum || baselineBenotungsdatum(context),
@@ -274,10 +281,7 @@ export const seedBaseline = (context, studentUid, options = {}) => {
}).then((seeded) => {
if (!erstantritt) return cy.wrap(seeded, { log: false });
- const student = context.students.find((s) => s.uid === studentUid);
- expect(student, `student ${studentUid} in the loaded LV`).to.exist;
-
- return performSeedPruefung(context, studentUid, {
+ return performSeedPruefung(context, student.uid, {
lehreinheitId: student.lehreinheit_id,
note,
datum: baselineDate(context),
@@ -296,11 +300,13 @@ export const seedPruefung = (context, student, { note, datum, typ }) =>
});
/** Zeugnisnote setzen. Nur die Studierendenverwaltung schreibt sie, kein Endpunkt dieses Tools. */
-export const seedZeugnisnote = (context, studentUid, note) =>
- performSeedZeugnisnote(context, studentUid, { note });
+export const seedZeugnisnote = (context, studentUid, note) => performSeedZeugnisnote(context, studentUid, { note });
-/** Raw row, bypassing the freigabedatum filter of getLvGesamtNoten. */
-export const readLvGesamtnote = (context, studentUid) => performRead(context, studentUid);
+/**
+ * Liest die LV-Note direkt aus der DATENBANK: was wirklich gespeichert ist, ohne den
+ * freigabedatum-Filter von getLvGesamtNoten. Das Gegenstück ist readStateViaApi.
+ */
+export const readLvGesamtnoteViaDb = (context, studentUid) => performRead(context, studentUid);
/** Anchor date; attempt dates derive from it so ordering is known. */
export const baselineBenotungsdatum = (context) => `${Number(context.semKurzbz.slice(2, 6))}-01-10 08:00:00`;
diff --git a/tests/cypress/support/pages/benotungstool.po.js b/tests/cypress/support/pages/benotungstool.po.js
index 3fc504383..daa7be82d 100644
--- a/tests/cypress/support/pages/benotungstool.po.js
+++ b/tests/cypress/support/pages/benotungstool.po.js
@@ -19,8 +19,7 @@ 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".
*/
-const exactText = (text) =>
- new RegExp(`^\\s*${String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`);
+const exactText = (text) => new RegExp(`^\\s*${String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`);
class BenotungstoolPage {
selectors = {
@@ -73,10 +72,8 @@ class BenotungstoolPage {
getFreigabeState = (uid) => this.getCell(uid, "freigegeben").find("[data-cy='freigabe-state']");
getPruefungCell = (uid, spalte) => this.getCell(uid, spalte).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, spalte) => this.getCell(uid, spalte).find("[data-cy='btn-pruefung-add']");
+ getPruefungEditButton = (uid, spalte) => this.getCell(uid, spalte).find("[data-cy='btn-pruefung-edit']");
getPruefungModal = () => cy.get("[data-cy='modal-pruefung']");
getNeuePruefungModal = () => cy.get("[data-cy='modal-neue-pruefung']");
@@ -89,8 +86,7 @@ class BenotungstoolPage {
// --- Zustand prüfen --------------------------------------------------------------------------
/** offen | changed | ok */
- expectFreigabeState = (uid, state) =>
- this.getFreigabeState(uid).should("have.attr", "data-state", state);
+ expectFreigabeState = (uid, state) => this.getFreigabeState(uid).should("have.attr", "data-state", state);
expectLvNote = (uid, bezeichnung) => this.getCell(uid, "lv_note").should("contain.text", bezeichnung);
@@ -149,7 +145,11 @@ class BenotungstoolPage {
cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("have.length.at.least", anzahl);
/** Die uids des letzten Requests eines Sammelpfads. feld: "noten" oder "pruefungen" */
- gesendeteUids = (alias, feld) => cy.get(alias).its(`request.body.${feld}`).then((zeilen) => zeilen.map((z) => z.uid));
+ gesendeteUids = (alias, feld) =>
+ cy
+ .get(alias)
+ .its(`request.body.${feld}`)
+ .then((zeilen) => zeilen.map((z) => z.uid));
// --- Notenvorschlag --------------------------------------------------------------------------
@@ -184,8 +184,7 @@ class BenotungstoolPage {
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}`);
+ setDatum = (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
@@ -279,8 +278,7 @@ 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 });
+ typeFreigabePasswort = (password) => cy.get("[data-cy='freigabe-passwort'] input").type(password, { log: false });
submitFreigabe = () => cy.get("[data-cy='freigabe-submit']").click();
@@ -349,8 +347,7 @@ class BenotungstoolPage {
waitForOk("@getNoteByPunkte");
};
- expectPunkte = (uid, punkte) =>
- this.getPunkteCell(uid).should("contain.text", String(punkte));
+ 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) => {
@@ -373,14 +370,12 @@ 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));
+ setNeuePruefungPunkte = (punkte) => cy.get("[data-cy='neue-pruefung-punkte'] input").clear().type(String(punkte));
// --- Hilfen ----------------------------------------------------------------------------------
/** Die Bezeichnung zu einer Noten-PK, wie sie in Dropdowns und Zellen steht. */
- bezeichnungOf = (ctx, note) =>
- (ctx.notenOptions ?? []).find((n) => String(n.note) === String(note))?.bezeichnung;
+ bezeichnungOf = (ctx, note) => (ctx.notenOptions ?? []).find((n) => String(n.note) === String(note))?.bezeichnung;
toDDMMYYYY = (isoDate) => {
const [y, m, d] = isoDate.split("-");
@@ -388,8 +383,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);
+ importDatum = (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 8bcd3f09f..d97a9be01 100644
--- a/tests/cypress/tasks/db.js
+++ b/tests/cypress/tasks/db.js
@@ -24,7 +24,6 @@ const getPool = () => {
if (pool) return pool;
// lazy require so the suite loads without pg when no DB task is used
- // eslint-disable-next-line global-require
const { Pool } = require("pg");
// with a tunnel up, bind to its local end - that is what gets us past pg_hba
@@ -37,8 +36,16 @@ const getPool = () => {
user: env("USER"),
password: env("PASSWORD"),
max: 4,
+ // tells these connections apart from the web application in pg_stat_activity
+ application_name: "cypress",
+ connectionTimeoutMillis: 15000,
+ // the server ends a stuck statement or transaction before the 60 s task timeout, so no lock wait holds a connection
+ statement_timeout: 30000,
+ idle_in_transaction_session_timeout: 30000,
ssl: String(env("SSL")).toLowerCase() === "true" ? { rejectUnauthorized: false } : undefined,
});
+ // an idle client that loses its connection emits here; without a listener the config process crashes
+ pool.on("error", (error) => console.warn(`[db] idle connection lost: ${error.message}`));
return pool;
};
@@ -115,19 +122,27 @@ const checkAvailability = async ({ requireWrites = true } = {}) => {
} catch (error) {
// a failed pool is poisoned - drop it so a retry reconnects cleanly
if (pool) {
- try { await pool.end(); } catch (e) { /* ignore */ }
+ const failed = pool;
pool = null;
+ try {
+ await failed.end();
+ } catch (e) {
+ /* ignore */
+ }
}
return { available: false, reason: explainFailure(error) };
}
};
const closeDb = async () => {
- if (pool) {
- await pool.end();
- pool = null;
+ // 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();
}
- await closeTunnel();
return null;
};
diff --git a/tests/cypress/tasks/notenDb.js b/tests/cypress/tasks/notenDb.js
index 83380e41d..152ab2916 100644
--- a/tests/cypress/tasks/notenDb.js
+++ b/tests/cypress/tasks/notenDb.js
@@ -9,9 +9,7 @@
* No unscoped DELETE in this file.
*/
-const {
- assertWritable, dbConfigured, withClient, inTransaction, checkAvailability, closeDb,
-} = require("./db");
+const { assertWritable, dbConfigured, withClient, inTransaction, checkAvailability, closeDb } = require("./db");
const REQUIRED_SCOPE = ["lvId", "semKurzbz", "studentUids"];
@@ -83,8 +81,15 @@ const seedLvGesamtnote = async (scope) => {
assertScope({ ...scope, studentUids: [scope.studentUid] });
const {
- lvId, semKurzbz, studentUid, note, punkte = null, mitarbeiterUid,
- benotungsdatum, freigegeben = false, freigabedatum = null,
+ lvId,
+ semKurzbz,
+ studentUid,
+ note,
+ punkte = null,
+ mitarbeiterUid,
+ benotungsdatum,
+ freigegeben = false,
+ freigabedatum = null,
} = scope;
if (note === undefined || note === null) throw new Error("noten:db seed - note is required");
@@ -107,8 +112,15 @@ const seedLvGesamtnote = async (scope) => {
mitarbeiter_uid, benotungsdatum, freigabedatum, freigabevon_uid, insertamum, insertvon)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $6)`,
[
- studentUid, lvId, semKurzbz, note, punkte, mitarbeiterUid,
- benotungsdatum, resolvedFreigabe, resolvedFreigabe ? mitarbeiterUid : null,
+ studentUid,
+ lvId,
+ semKurzbz,
+ note,
+ punkte,
+ mitarbeiterUid,
+ benotungsdatum,
+ resolvedFreigabe,
+ resolvedFreigabe ? mitarbeiterUid : null,
],
);
@@ -140,7 +152,9 @@ const seedPruefung = async (scope) => {
[lehreinheitId, lvId, semKurzbz],
);
if (!owned.rowCount) {
- throw new Error(`noten:db seedPruefung refused - lehreinheit ${lehreinheitId} is outside ${lvId}/${semKurzbz}`);
+ throw new Error(
+ `noten:db seedPruefung refused - lehreinheit ${lehreinheitId} is outside ${lvId}/${semKurzbz}`,
+ );
}
const res = await client.query(
@@ -336,7 +350,8 @@ const lehrendeDerLehreinheit = async ({ lehreinheitId } = {}) => {
/** The grading person of one exam row, or null. */
const pruefungMitarbeiter = async ({ pruefungId, studentUid } = {}) => {
assertReadable();
- if (!pruefungId || !studentUid) throw new Error("noten:db pruefungMitarbeiter - pruefungId and studentUid are required");
+ if (!pruefungId || !studentUid)
+ throw new Error("noten:db pruefungMitarbeiter - pruefungId and studentUid are required");
const rows = await readRows(
"SELECT mitarbeiter_uid FROM lehre.tbl_pruefung WHERE pruefung_id = $1 AND student_uid = $2",
diff --git a/tests/cypress/tasks/notenProfil.js b/tests/cypress/tasks/notenProfil.js
index bc89fe875..9b53f229c 100644
--- a/tests/cypress/tasks/notenProfil.js
+++ b/tests/cypress/tasks/notenProfil.js
@@ -46,12 +46,15 @@ const sshKonfiguriert = () => Boolean(envWert("SSH_HOST") && envWert("SSH_USER")
const verbinde = () =>
new Promise((resolve, reject) => {
- // eslint-disable-next-line global-require
const { Client } = require("ssh2");
let auth;
try {
- auth = resolveAuth({ keyPath: envWert("SSH_KEY"), passphrase: envWert("SSH_PASSPHRASE"), agent: envWert("SSH_AGENT") });
+ auth = resolveAuth({
+ keyPath: envWert("SSH_KEY"),
+ passphrase: envWert("SSH_PASSPHRASE"),
+ agent: envWert("SSH_AGENT"),
+ });
} catch (error) {
reject(error);
return;
@@ -60,7 +63,9 @@ const verbinde = () =>
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}`)))
+ .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),
@@ -133,7 +138,9 @@ const mitFlags = (original, flags) => {
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.`);
+ 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)});`);
});
@@ -198,7 +205,8 @@ const setze = async (sftp, zustand, schluessel, umbau) => {
}
original = await lies(sftp, sicherung);
} else {
- if (eintrag) console.warn(`[profil] ${entfernt} ist seit dem letzten Profil neu ausgerollt und gilt als Original.`);
+ if (eintrag)
+ console.warn(`[profil] ${entfernt} ist seit dem letzten Profil neu ausgerollt und gilt als Original.`);
await schreibeRoh(sftp, sicherung, aktuell, 0o600);
}
@@ -232,7 +240,9 @@ const leseCisConfig = async (url, auth) => {
// 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)}`);
+ 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}` };
};
@@ -259,7 +269,9 @@ const pruefeInstanz = async (daten) => {
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])}`);
+ 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")}`;
}
@@ -295,7 +307,9 @@ const anwenden = async (name) => {
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.`);
+ throw new Error(
+ `Profil "${name}" braucht ${PREFIX}_SSH_HOST, ${PREFIX}_SSH_USER und ${PREFIX}_REMOTE_ROOT in tests/cypress/.env.`,
+ );
}
await mitSftp(async (sftp) => {
@@ -303,12 +317,15 @@ const anwenden = async (name) => {
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`);
+ 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));
+ 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));
diff --git a/tests/cypress/tasks/sshTunnel.js b/tests/cypress/tasks/sshTunnel.js
index 35ff49f25..a7ad23e06 100644
--- a/tests/cypress/tasks/sshTunnel.js
+++ b/tests/cypress/tasks/sshTunnel.js
@@ -53,7 +53,6 @@ const openTunnel = (cfg) =>
if (localPort) return resolve(localPort);
if (!cfg.sshHost || !cfg.sshUser) return reject(new Error("Tunnel needs an SSH host and user."));
- // eslint-disable-next-line global-require
const { Client } = require("ssh2");
let auth;
@@ -68,6 +67,8 @@ const openTunnel = (cfg) =>
sshClient
.on("ready", () => {
server = net.createServer((socket) => {
+ // an error before the channel is up must not crash the process
+ socket.on("error", () => socket.destroy());
sshClient.forwardOut(
socket.remoteAddress || "127.0.0.1",
socket.remotePort || 0,
@@ -75,6 +76,10 @@ const openTunnel = (cfg) =>
cfg.dbPort,
(err, stream) => {
if (err) return socket.destroy();
+ if (socket.destroyed) return stream.destroy();
+ // pipe forwards only a clean end; an aborted side must close the other, or the channel keeps its Postgres connection
+ socket.on("close", () => stream.destroy());
+ stream.on("error", () => socket.destroy()).on("close", () => socket.destroy());
socket.pipe(stream).pipe(socket);
},
);
@@ -85,7 +90,6 @@ const openTunnel = (cfg) =>
// port 0: let the OS pick, so a leftover process cannot collide
server.listen(0, "127.0.0.1", () => {
localPort = server.address().port;
- // eslint-disable-next-line no-console
console.log(
`[db] SSH tunnel up: 127.0.0.1:${localPort} -> ${cfg.dbHost}:${cfg.dbPort} ` +
`via ${cfg.sshUser}@${cfg.sshHost} (${auth.description})`,
@@ -114,14 +118,15 @@ const openTunnel = (cfg) =>
});
const closeTunnel = async () => {
- if (server) {
- await new Promise((resolve) => server.close(resolve));
- server = null;
- }
+ // the SSH connection first: its closing channels close the local sockets, which server.close waits for
if (sshClient) {
sshClient.end();
sshClient = null;
}
+ if (server) {
+ await new Promise((resolve) => server.close(resolve));
+ server = null;
+ }
localPort = null;
return null;
};
diff --git a/tests/cypress/tools/checks/noten.js b/tests/cypress/tools/checks/noten.js
index cc0f88680..944910024 100644
--- a/tests/cypress/tools/checks/noten.js
+++ b/tests/cypress/tools/checks/noten.js
@@ -9,9 +9,18 @@
const path = require("path");
// tests/cypress/tools/checks -> repo root is four levels up
-const seeder = (name) => path.join(__dirname, "..", "..", "..", "..", "system", "seeders", name);
+const SEEDER = path.join(
+ __dirname,
+ "..",
+ "..",
+ "..",
+ "..",
+ "system",
+ "seeders",
+ "TST60873_000_GesamtnoteneingabeCis4.sql",
+);
-/** Test semester: the running one, else the last started. Same rule as the suite and seeder 016. */
+/** Test semester: the running one, else the last started. Same rule as the suite and seeder group benotungstool_noten. */
const SEM = `(SELECT studiensemester_kurzbz FROM public.tbl_studiensemester
WHERE start <= now() ORDER BY (ende >= now()) DESC, start DESC LIMIT 1)`;
@@ -19,7 +28,10 @@ module.exports = {
title: "Gesamtnoteneingabe",
sqlFiles: {
- seed: [seeder("016_benotungstool_noten.sql"), seeder("019_benotungstool_fixture_erweitert.sql")],
+ // the idempotent fixture groups; studiengang_5 inserts without ON CONFLICT and needs a rebuild
+ seed: ["benotungstool_noten", "benotungstool_berechtigungen", "benotungstool_fixture_erweitert"].map(
+ (group) => ({ file: SEEDER, group }),
+ ),
},
checks: [
@@ -27,20 +39,20 @@ module.exports = {
label: "Test semester (latest started)",
sql: `SELECT ${SEM} AS value`,
ok: (r) => Boolean(r.value),
- hint: "No studiensemester with start <= now(). Apply the base dump + 003_kompetenzfeld.sql.",
+ hint: "No studiensemester with start <= now(). Apply the base dump + BS003_kompetenzfeld.sql.",
},
{
label: "demolektor1 exists",
sql: `SELECT COUNT(*)::int AS value FROM public.tbl_mitarbeiter WHERE mitarbeiter_uid = 'demolektor1'`,
ok: (r) => r.value > 0,
- hint: "Apply 002_mitarbeiter.sql.",
+ hint: "Apply BS002_mitarbeiter.sql.",
},
{
label: "STG5 Lehrveranstaltungen (5121 / 5221)",
sql: `SELECT COUNT(*)::int AS value FROM lehre.tbl_lehrveranstaltung
WHERE lehrveranstaltung_id IN (5121, 5221)`,
ok: (r) => r.value === 2,
- hint: "Apply 013_studiengang_5.sql.",
+ hint: "Rebuild the database. Seeder group studiengang_5 creates them.",
},
{
label: "Lehreinheiten of demolektor1 in the test semester",
@@ -50,8 +62,8 @@ module.exports = {
WHERE mitarbeiter_uid = 'demolektor1' AND le.studiensemester_kurzbz = ${SEM}`,
ok: (r) => r.value > 0,
hint:
- "demolektor1 teaches nothing in the CURRENT test semester. The 013 seeder pins its " +
- "Lehreinheiten to the semester that was active when it ran. Apply 016_benotungstool_noten.sql.",
+ "demolektor1 teaches nothing in the CURRENT test semester. Seeder group studiengang_5 pins its" +
+ "Lehreinheiten to the semester that was active when it ran. Apply seeder group benotungstool_noten.",
},
{
label: "Lehreinheitgruppe rows with a non-NULL gruppe_kurzbz",
@@ -60,7 +72,7 @@ module.exports = {
ok: (r) => r.value > 0,
hint:
"campus.vw_student_lehrveranstaltung joins ONLY on gruppe_kurzbz. Lehrverband-style rows " +
- "(verband set, gruppe_kurzbz NULL) never match. Apply 016_benotungstool_noten.sql.",
+ "(verband set, gruppe_kurzbz NULL) never match. Apply seeder group benotungstool_noten.",
},
{
label: "Group memberships carrying a studiensemester",
@@ -69,14 +81,14 @@ module.exports = {
ok: (r) => r.value > 0,
hint:
"tbl_benutzergruppe.studiensemester_kurzbz must equal tbl_lehreinheit.studiensemester_kurzbz. " +
- "NULL never matches. Apply 016_benotungstool_noten.sql.",
+ "NULL never matches. Apply seeder group benotungstool_noten.",
},
{
label: "Students visible in LV 5221 (the test LV)",
sql: `SELECT COUNT(*)::int AS value FROM campus.vw_student_lehrveranstaltung
WHERE lehrveranstaltung_id = 5221 AND studiensemester_kurzbz = ${SEM}`,
ok: (r) => r.value >= 3,
- hint: "The suite needs at least 3 enrolled students. Apply 016_benotungstool_noten.sql.",
+ hint: "The suite needs at least 3 enrolled students. Apply seeder group benotungstool_noten.",
},
{
label: "tbl_note 'entschuldigt' (resolved by Bezeichnung)",
@@ -100,7 +112,7 @@ module.exports = {
ok: (r) => r.value.includes("Termin1") && r.value.includes("Termin2"),
hint:
"Termin3 is absent from the base dump. Harmless while CIS_GESAMTNOTE_PRUEFUNG_TERMIN3 is " +
- "off, but a Termin3 insert would hit a foreign key error. 016 adds it.",
+ "off, but a Termin3 insert would hit a foreign key error. Seeder group benotungstool_noten adds it.",
},
{
label: "Notenschluessel assigned to LV 5221",
@@ -111,20 +123,20 @@ module.exports = {
ok: (r) => r.value > 0,
hint:
"The Notenschluessel tables are empty in the base dump, so getNoteByPunkte always returns " +
- "null. Apply 016_benotungstool_noten.sql.",
+ "null. Apply seeder group benotungstool_noten.",
},
{
label: "Students in the second Lehreinheit of LV 5221",
sql: `SELECT COUNT(*)::int AS value FROM campus.vw_student_lehrveranstaltung
WHERE lehreinheit_id = 51103 AND studiensemester_kurzbz = ${SEM}`,
ok: (r) => r.value > 0,
- hint: "The Lehreinheit check of the write paths needs it. Apply 019_benotungstool_fixture_erweitert.sql.",
+ hint: "The Lehreinheit check of the write paths needs it. Apply seeder group benotungstool_fixture_erweitert.",
},
{
label: "Lektoren of Lehreinheit 51103",
sql: "SELECT COUNT(*)::int AS value FROM lehre.tbl_lehreinheitmitarbeiter WHERE lehreinheit_id = 51103",
ok: (r) => r.value >= 2,
- hint: "The grader rules need two Lektoren. Apply 019_benotungstool_fixture_erweitert.sql.",
+ hint: "The grader rules need two Lektoren. Apply seeder group benotungstool_fixture_erweitert.",
},
{
label: "Sommersemester after its deadline, taught by demolektor1",
@@ -136,7 +148,7 @@ module.exports = {
AND make_date(substring(le.studiensemester_kurzbz FROM 3 FOR 4)::int, 11, 15) < current_date
ORDER BY le.studiensemester_kurzbz DESC LIMIT 1), '') AS value`,
ok: (r) => r.value !== "",
- hint: "The deadline tests need it. Apply 019_benotungstool_fixture_erweitert.sql.",
+ hint: "The deadline tests need it. Apply seeder group benotungstool_fixture_erweitert.",
},
{
label: "Texts of the Vorlagen Notenfreigabe and Sancho_Mail_Template",
@@ -144,7 +156,7 @@ module.exports = {
WHERE vorlage_kurzbz IN ('Notenfreigabe', 'Sancho_Mail_Template') AND aktiv
AND COALESCE(text, '') <> ''`,
ok: (r) => r.value === 2,
- hint: "Without both texts every release mail has an empty body. Apply 019_benotungstool_fixture_erweitert.sql.",
+ hint: "Without both texts every release mail has an empty body. Apply seeder group benotungstool_fixture_erweitert.",
},
],
};
diff --git a/tests/cypress/tools/dbCheck.js b/tests/cypress/tools/dbCheck.js
index 016f88ee1..1d80cd34f 100644
--- a/tests/cypress/tools/dbCheck.js
+++ b/tests/cypress/tools/dbCheck.js
@@ -7,7 +7,8 @@
* 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.
+ * tunnel 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.
*/
@@ -32,12 +33,14 @@ const loadSuite = (name) => {
const file = path.join(CHECKS_DIR, `${name}.js`);
if (!fs.existsSync(file)) {
const available = fs.existsSync(CHECKS_DIR)
- ? fs.readdirSync(CHECKS_DIR).map((f) => f.replace(/\.js$/, "")).join(", ")
+ ? fs
+ .readdirSync(CHECKS_DIR)
+ .map((f) => f.replace(/\.js$/, ""))
+ .join(", ")
: "(none)";
console.error(`\nUnknown suite "${name}". Available: ${available}\n`);
process.exit(2);
}
- // eslint-disable-next-line global-require, import/no-dynamic-require
return require(file);
};
@@ -74,7 +77,18 @@ const runChecks = async (client, suite) => {
return 1;
};
-const applySqlFile = async (client, file) => {
+/** The SQL of a file, or of one group in it. A group ends at the next group header. */
+const readSql = (file, group) => {
+ const sql = fs.readFileSync(file, "utf8");
+ if (!group) return sql;
+ const parts = sql.split(/^-- ==== (.+?) ====[ \t]*\r?$/m);
+ for (let i = 1; i < parts.length; i += 2) if (parts[i] === group) return parts[i + 1];
+ return null;
+};
+
+const applySqlFile = async (client, entry) => {
+ const { file, group } = typeof entry === "string" ? { file: entry } : entry;
+ const label = path.relative(process.cwd(), file) + (group ? ` [${group}]` : "");
if (!db.writesAllowed()) {
console.error(`\nRefusing to write: set ${db.PREFIX}_DB_ALLOW_WRITES=true first.\n`);
return 2;
@@ -84,13 +98,19 @@ const applySqlFile = async (client, file) => {
return 2;
}
- console.log(`\nApplying ${path.relative(process.cwd(), file)} ...\n`);
+ const sql = readSql(file, group);
+ if (sql === null) {
+ console.error(`\nGroup "${group}" not found in ${file}\n`);
+ return 2;
+ }
+
+ console.log(`\nApplying ${label} ...\n`);
// One connection, one call: the seeders rely on running as a unit (temp tables, DO blocks and a
// closing verification query).
let result;
try {
- result = await client.query(fs.readFileSync(file, "utf8"));
+ result = await client.query(sql);
} catch (error) {
// `check` gets by as the application role; seeding creates and grants objects.
if (/permission denied/i.test(error.message)) {
@@ -107,11 +127,10 @@ const applySqlFile = async (client, file) => {
const last = results.filter((r) => r && r.rows && r.rows.length).pop();
if (last) {
console.log("Verification\n" + "-".repeat(50));
- last.rows.forEach((row) =>
- Object.entries(row).forEach(([k, v]) => console.log(` ${k.padEnd(24)} ${v}`)));
+ last.rows.forEach((row) => Object.entries(row).forEach(([k, v]) => console.log(` ${k.padEnd(24)} ${v}`)));
}
- console.log(`\nDone: ${path.basename(file)}\n`);
+ console.log(`\nDone: ${label}\n`);
return 0;
};
@@ -175,7 +194,7 @@ const main = async () => {
client.on("notice", (n) => console.log(` NOTICE: ${n.message}`));
if (mode !== "check") {
- // a suite can name several files; they run in this order
+ // a suite can name several files or groups; they run in this order
for (const file of [].concat(suite.sqlFiles[mode])) {
const code = await applySqlFile(client, file);
if (code !== 0) return code;