From 013d69e5ab739a5abcbaf763ca6e086f2c5e9857 Mon Sep 17 00:00:00 2001 From: filzmann Date: Tue, 28 Jul 2026 18:05:40 +0200 Subject: [PATCH 01/10] Add approved AdPlaner localization task --- ROADMAP.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index ee24f56..278406d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,24 @@ Diese Datei bündelt geplante Erweiterungen und offene Produktentscheidungen. Verbindliche Fach-, Sicherheits- und Architekturregeln stehen in `AGENTS.md`. +## Freigegebene Umsetzungsaufgaben + +### ADP-L10N – Assistenzplanung vollständig lokalisieren + +Status: als l10n-Pilot geeignet + +- Manuelle Monats-/Wochentagsnamen und sichtbare UI-, Status-, Validierungs- + und Fehlermeldungen auf aktive Nextcloud-Locale und Nextcloud-l10n + umstellen. +- ISO-Daten, Monatsnummern, Schichtzeiten, Statuswerte, Teamcodes und + API-Schlüssel unverändert lassen; Abkürzungen nicht durch Abschneiden + bilden. +- Deutsche Ausgabe, eine weitere Locale, Fallback, Monats-/Jahresgrenzen, + Pluralformen, Platzhalter und Escaping in PHP und JavaScript testen. +- Erst nach vollständiger Pilotmigration den app-eigenen Rohtext-Check + verbindlich schalten und seinen Vertrag für die weiteren Apps + dokumentieren. + ## Aktueller Fokus - Produktive Rechte- und Datenschutzprüfung der Wunschdienstplanung. From b0e5c6cbd1bc1fc7429ced60f43c996840587f94 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sat, 1 Aug 2026 18:35:42 +0200 Subject: [PATCH 02/10] Document standalone acceptance --- README.md | 4 ++ ROADMAP.md | 2 + docs/manual-acceptance.md | 84 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 docs/manual-acceptance.md diff --git a/README.md b/README.md index 1dc53d5..0907a98 100644 --- a/README.md +++ b/README.md @@ -21,4 +21,8 @@ Assistenzteams werden aus den zentral konfigurierten Nextcloud-Gruppen abgeleite Geplante Erweiterungen und offene Produktentscheidungen stehen in der [Roadmap](ROADMAP.md). +Für die fachliche, visuelle und sicherheitsbezogene Staging-Prüfung steht ein +ausfüllbares [manuelles Abnahmeformular](docs/manual-acceptance.md) bereit. +Zugangsdaten und personenbezogene Echtdaten werden darin nicht dokumentiert. + Installations-, Betriebs- und Abnahmeunterlagen stehen im öffentlichen [AD-Suite-Projekt](https://github.com/Filzmann/ad-suite). diff --git a/ROADMAP.md b/ROADMAP.md index 278406d..c2e9b05 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,6 +22,8 @@ Status: als l10n-Pilot geeignet ## Aktueller Fokus +- Die manuellen Prüfungen werden im ausfüllbaren + [`docs/manual-acceptance.md`](docs/manual-acceptance.md) dokumentiert. - Produktive Rechte- und Datenschutzprüfung der Wunschdienstplanung. - Monatsplan, variable Schichten, EB-Koordination und Standalone-Betrieb auf einem realitätsnahen Staging fachlich abnehmen. diff --git a/docs/manual-acceptance.md b/docs/manual-acceptance.md new file mode 100644 index 0000000..01901b0 --- /dev/null +++ b/docs/manual-acceptance.md @@ -0,0 +1,84 @@ +# Manuelles Abnahmeformular – AdPlaner + +Dieses Formular dokumentiert die fachliche und visuelle Abnahme der +Assistenzplanung auf einem realitätsnahen Staging-System. Pro Prüffall wird +genau ein Ergebnis markiert und unter „Warum/Beleg/Abweichung“ knapp +festgehalten, was beobachtet wurde. + +Keine personenbezogenen Echtdaten, Gesundheits- oder Urlaubsdetails, +Zugangsdaten oder internen Kennungen eintragen. Ausschließlich neutrale +Testkonten, synthetische Teams, Schichten und Bemerkungen verwenden. + +## Kopfdaten + +| Feld | Eintrag | +|---|---| +| Datum und Uhrzeit | | +| Prüfer*in | | +| Umgebung und URL | | +| AdPlaner-Version | | +| Nextcloud-Version | | +| Browser und Version | | +| Fenstergröße / Zoom | | +| Neutrale Testkonten, Teams und Rollen | | +| Aktive optionale Apps | | + +Ergebniskennzeichnung: `[ ] erfolgreich` / `[ ] nicht erfolgreich` / +`[ ] nicht geprüft`. Bei „nicht erfolgreich“ oder „nicht geprüft“ ist eine +Begründung verpflichtend. + +## A. Einstieg, Navigation und Monatsplan + +| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | +|---|---|---|---|---|---| +| A1 | Standalone-Einstieg | AdPlaner ohne aktive OrgSuite öffnen. | Ein eigener Nextcloud-Einstieg ist vorhanden und der Monatsplan wird ohne andere Fachapps geladen. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| A2 | Suite-Einstieg | AdPlaner mit aktiver OrgSuite über den AD-Einstieg öffnen und zwischen aktivierten AD-Apps wechseln. | Es gibt keinen doppelten Haupteinstieg; AdPlaner ist im gemeinsamen Menü korrekt markiert. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| A3 | Team und Monat | Zwischen mindestens zwei synthetischen Teams sowie vorherigem und nächstem Monat wechseln. | Auswahl, Überschrift, Tage, Schichten und Zuweisungen gehören stets zum gewählten Team und Monat. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| A4 | Monatsgrenzen | Februar sowie einen Monats-/Jahreswechsel öffnen. | Kalendertage und gespeicherte Planwerte werden ohne fehlende oder doppelte Tage angezeigt. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| A5 | Tastatur und Fokus | Team-, Monats-, Tab- und Plansteuerung nur mit Tastatur bedienen. | Alle Funktionen sind erreichbar, der Fokus ist sichtbar und die Tabs melden Auswahl und Zielbereich korrekt. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| A6 | Responsivität und Scrollen | Viele Schichten und Personen bei kleinem Fenster anzeigen und horizontal sowie vertikal scrollen. | App-Inhalte bleiben erreichbar; der App-Root scrollt vertikal und breite Planungselemente sprengen nicht die Nextcloud-Seite. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | + +## B. Schichtkonfiguration und Wunschplanung + +| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | +|---|---|---|---|---|---| +| B1 | Variable Schichtliste | Als zuständige EB eine Schicht ergänzen, umbenennen, zeitlich ändern, deaktivieren und wieder aktivieren. | Die strukturierte Teamkonfiguration bleibt nach Neuladen erhalten und steuert den Monatsplan. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| B2 | Lücken und Überlappungen | Synthetische Schichten mit einer Lücke und einer zeitlichen Überlappung speichern. | Beide fachlich zulässigen Konfigurationen werden nicht vorschnell abgewiesen und erscheinen nachvollziehbar. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| B3 | Schicht über Mitternacht | Eine Nachtschicht mit Ende am Folgetag konfigurieren und im Monatsplan prüfen. | Die Schicht wird dem Ausgangstag eindeutig zugeordnet und mit ihrem Zeitraum verständlich dargestellt. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| B4 | Eigener Wunsch | Als schichtfähiges Teammitglied einen eigenen Wunsch hinzufügen, Bemerkung speichern und den Wunsch wieder entfernen. | Nur der eigene Eintrag wird verändert; Status und Bemerkung bleiben nach Neuladen erhalten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| B5 | Fremdzuweisung durch EB | Als zuständige EB eine andere schichtfähige Person zuweisen und wieder entfernen. | Die Zuweisung ist möglich und betrifft ausschließlich das gewählte Team und die gewählte Schicht. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| B6 | EB nicht schichtfähig | Versuchen, das reine EB-Testkonto selbst einer Schicht zuzuweisen. | Das EB-Konto wird nicht als schichtfähige Person angeboten beziehungsweise serverseitig abgewiesen. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| B7 | Planstatus | Als EB die angebotenen Statuswechsel einschließlich `planned` und `approved` durchführen; als normales Teammitglied wiederholen. | Nur die EB kann den Status wechseln; unberechtigte Versuche verändern den Plan nicht. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | + +## C. Team- und Rechteabgrenzung + +| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | +|---|---|---|---|---|---| +| C1 | Teammitgliedschaft | Mit einem neutralen Konto aus Team A Team A und Team B direkt aufrufen. | Nur der erlaubte Teamkontext ist nutzbar; ein direkter unberechtigter Request auf Team B wird serverseitig abgewiesen. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| C2 | EB-Schnittmenge | Ein Testkonto nur in der EB-Rollengruppe, ein Konto nur im Team und ein Konto in beiden Gruppen vergleichen. | EB-Rechte entstehen nur aus der vorgesehenen Team-und-Rolle-Schnittmenge. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| C3 | Fremdänderung durch normales Mitglied | Als normales Teammitglied eine fremde Zuweisung über UI und direkten API-Aufruf ändern. | Beide Wege werden abgewiesen; der bestehende Eintrag bleibt unverändert. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| C4 | Bereichs- und Teamtrennung | Zwei Teams mit ähnlich benannten neutralen Konten und unterschiedlichen Konfigurationen prüfen. | Zuweisungen, Schichten, Bemerkungen und Rechte werden nicht zwischen Teams vermischt. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| C5 | Demo-Pack-Schutz | Adminbereich öffnen, Installation ohne Bestätigung versuchen und anschließend nur in einer dafür vorgesehenen Testumgebung bestätigen. | Ohne ausdrückliche Bestätigung bleibt die Aktion gesperrt; es werden ausschließlich synthetische Konten und Teams verwendet. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | + +## D. Optionale Integrationen und Fehlerzustände + +| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | +|---|---|---|---|---|---| +| D1 | Betrieb ohne AD Urlaub | AD Urlaub deaktiviert lassen und Monatsplan, Einstellungen und Zuweisungen prüfen. | Der Monatsplan bleibt vollständig nutzbar; fehlende Abwesenheitshinweise blockieren keine Aktion. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| D2 | Betrieb ohne AD Kalender | AD Kalender deaktiviert lassen und dieselben Kernabläufe wiederholen. | Die Assistenzplanung bleibt nutzbar; der fehlende optionale Provider wird nicht als Planfehler behandelt. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| D3 | Optionale Hinweise | Mit aktivem, neutral vorbereitetem Urlaubs- oder Kalenderprovider dessen Hinweise im Monatsplan prüfen. | Hinweise werden read-only dargestellt und verändern weder Teamrechte noch die führenden AdPlaner-Daten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| D4 | Verständliche Validierung | Leere Namen, unvollständige Zeiten und ungültige Eingaben in Einstellungen und Planung versuchen. | Fehler werden am richtigen Kontext verständlich angezeigt; gültige bestehende Daten bleiben erhalten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| D5 | Datensparsame Abnahme | Formular und Screenshots prüfen. | Es wurden nur synthetische Team-, Dienst- und Kontodaten dokumentiert. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | + +## Abschlussentscheidung + +| Feld | Eintrag | +|---|---| +| Anzahl erfolgreich | | +| Anzahl nicht erfolgreich | | +| Anzahl nicht geprüft | | +| Kritische Abweichungen / Ticketreferenzen | | +| Erneute Prüfung erforderlich bis | | +| Gesamtentscheidung | [ ] abgenommen [ ] mit Auflagen abgenommen [ ] nicht abgenommen | +| Begründung der Gesamtentscheidung | | +| Name / Datum | | From 9bf5babb0e6968e03bd869c536f161cc9b223cf1 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 2 Aug 2026 13:49:28 +0200 Subject: [PATCH 03/10] Deploy main release candidates to staging --- .github/workflows/tests.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 76644ec..f038e43 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -106,3 +106,11 @@ jobs: --check-coverage \ --lines=86.47 \ node tests/run-js.mjs + + deploy-staging: + name: Staging-Deployment + if: github.event_name == 'push' + needs: [php, javascript] + uses: Filzmann/br-nextcloud-apps/.github/workflows/deploy-staging.yml@main + with: + app-id: adplaner From b24f4d03cfa9b88b31400805ea7a65c0569c24f0 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 2 Aug 2026 14:23:33 +0200 Subject: [PATCH 04/10] Forward staging deployment secrets --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f038e43..49e9b3f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -114,3 +114,4 @@ jobs: uses: Filzmann/br-nextcloud-apps/.github/workflows/deploy-staging.yml@main with: app-id: adplaner + secrets: inherit From a8f59a598ddc155b4c8abc387414b8e728f953c0 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sat, 8 Aug 2026 18:50:37 +0200 Subject: [PATCH 05/10] Document AdPlaner acceptance findings --- docs/manual-acceptance.md | 130 +++++++++++++++++++++++++------------- 1 file changed, 87 insertions(+), 43 deletions(-) diff --git a/docs/manual-acceptance.md b/docs/manual-acceptance.md index 01901b0..fbbcfdb 100644 --- a/docs/manual-acceptance.md +++ b/docs/manual-acceptance.md @@ -13,72 +13,116 @@ Testkonten, synthetische Teams, Schichten und Bemerkungen verwenden. | Feld | Eintrag | |---|---| -| Datum und Uhrzeit | | -| Prüfer*in | | -| Umgebung und URL | | -| AdPlaner-Version | | -| Nextcloud-Version | | -| Browser und Version | | -| Fenstergröße / Zoom | | -| Neutrale Testkonten, Teams und Rollen | | -| Aktive optionale Apps | | - -Ergebniskennzeichnung: `[ ] erfolgreich` / `[ ] nicht erfolgreich` / -`[ ] nicht geprüft`. Bei „nicht erfolgreich“ oder „nicht geprüft“ ist eine +| Datum und Uhrzeit | 02.08.2026, ca. 18:26–19:31 Uhr | +| Prüfer*in | Simon | +| Umgebung und URL | DEV/Staging – `https://nextcloud-dev.ddev.site/index.php/apps/adplaner/` | +| AdPlaner-Version | 0.3.0-rc.2 | +| Nextcloud-Version | Nextcloud Hub 26 Spring – 34.0.2 | +| Browser und Version | Google Chrome 150.0.7871.186, offizieller 64-Bit-Build unter Ubuntu | +| Fenstergröße / Zoom | ungefähr zwei Drittel von 1920 px, Zoom 100 % | +| Neutrale Testkonten, Teams und Rollen | `adc-test-eb-sued` (EB-Testkonto), `admin` (in AdPlaner normales schichtfähiges Teammitglied ohne EB-Rechte), `west` (Nur-EB-Testkontext); Teams `KaKü` und `HaHü`; weitere neutrale Testkonten im Verlauf verwendet, aber nicht einzeln dokumentiert | +| Aktive optionale Apps | Grundsätzlich alle, einschließlich OrgSuite, AD Urlaub und AD Kalender; OrgSuite für A1 vorübergehend deaktiviert, AD Urlaub für D1 und AD Kalender für D2 vorübergehend deaktiviert | + +Ergebniskennzeichnung: `[x] erfolgreich` / `[x] nicht erfolgreich` / +`[x] nicht geprüft`. Bei „nicht erfolgreich“ oder „nicht geprüft“ ist eine Begründung verpflichtend. ## A. Einstieg, Navigation und Monatsplan | ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | |---|---|---|---|---|---| -| A1 | Standalone-Einstieg | AdPlaner ohne aktive OrgSuite öffnen. | Ein eigener Nextcloud-Einstieg ist vorhanden und der Monatsplan wird ohne andere Fachapps geladen. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| A2 | Suite-Einstieg | AdPlaner mit aktiver OrgSuite über den AD-Einstieg öffnen und zwischen aktivierten AD-Apps wechseln. | Es gibt keinen doppelten Haupteinstieg; AdPlaner ist im gemeinsamen Menü korrekt markiert. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| A3 | Team und Monat | Zwischen mindestens zwei synthetischen Teams sowie vorherigem und nächstem Monat wechseln. | Auswahl, Überschrift, Tage, Schichten und Zuweisungen gehören stets zum gewählten Team und Monat. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| A4 | Monatsgrenzen | Februar sowie einen Monats-/Jahreswechsel öffnen. | Kalendertage und gespeicherte Planwerte werden ohne fehlende oder doppelte Tage angezeigt. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| A5 | Tastatur und Fokus | Team-, Monats-, Tab- und Plansteuerung nur mit Tastatur bedienen. | Alle Funktionen sind erreichbar, der Fokus ist sichtbar und die Tabs melden Auswahl und Zielbereich korrekt. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| A6 | Responsivität und Scrollen | Viele Schichten und Personen bei kleinem Fenster anzeigen und horizontal sowie vertikal scrollen. | App-Inhalte bleiben erreichbar; der App-Root scrollt vertikal und breite Planungselemente sprengen nicht die Nextcloud-Seite. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| A1 | Standalone-Einstieg | AdPlaner ohne aktive OrgSuite öffnen. | Ein eigener Nextcloud-Einstieg ist vorhanden und der Monatsplan wird ohne andere Fachapps geladen. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Eigener Nextcloud-Einstieg vorhanden; Monatsplan vollständig geladen und ohne OrgSuite bedienbar. Keine Fehler oder fehlenden Komponenten festgestellt. | +| A2 | Suite-Einstieg | AdPlaner mit aktiver OrgSuite über den AD-Einstieg öffnen und zwischen aktivierten AD-Apps wechseln. | Es gibt keinen doppelten Haupteinstieg; AdPlaner ist im gemeinsamen Menü korrekt markiert. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Gemeinsamer AD-Einstieg vorhanden; kein doppelter Haupteinstieg; AdPlaner im gemeinsamen Menü sichtbar und korrekt markiert. Wechsel zwischen AD-Apps und Laden des Monatsplans funktionieren. | +| A3 | Team und Monat | Zwischen mindestens zwei synthetischen Teams sowie vorherigem und nächstem Monat wechseln. | Auswahl, Überschrift, Tage, Schichten und Zuweisungen gehören stets zum gewählten Team und Monat. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Wechsel zwischen zwei Teams sowie vorherigem und nächstem Monat funktionierte korrekt. Überschrift, Tage, Schichten und Zuweisungen gehörten jeweils zum gewählten Team und Monat. Keine vermischten oder veralteten Daten sichtbar. | +| A4 | Monatsgrenzen | Februar sowie einen Monats-/Jahreswechsel öffnen. | Kalendertage und gespeicherte Planwerte werden ohne fehlende oder doppelte Tage angezeigt. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Februar einschließlich Schaltjahr sowie Monats-/Jahreswechsel geprüft. Keine fehlenden oder doppelten Tage; gespeicherte Planwerte korrekt. Verbesserungsbedarf: Es gibt keine Schalter für „vorheriger Monat“ und „nächster Monat“; der Wechsel ist nur über das Datumsfeld möglich. | +| A5 | Tastatur und Fokus | Team-, Monats-, Tab- und Plansteuerung nur mit Tastatur bedienen. | Alle Funktionen sind erreichbar, der Fokus ist sichtbar und die Tabs melden Auswahl und Zielbereich korrekt. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Teamauswahl, Monatssteuerung, Tabs und Plansteuerung waren per Tastatur bedienbar. Der Fokus war sichtbar, die aktive Tab-Auswahl erkennbar und es bestand keine Tastaturfalle. | +| A6 | Responsivität und Scrollen | Viele Schichten und Personen bei kleinem Fenster anzeigen und horizontal sowie vertikal scrollen. | App-Inhalte bleiben erreichbar; der App-Root scrollt vertikal und breite Planungselemente sprengen nicht die Nextcloud-Seite. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Kleines Fenster geprüft; vertikales Scrollen funktioniert, alle Inhalte bleiben erreichbar und die Nextcloud-Seite wird nicht horizontal gesprengt. Keine doppelten oder überlagernden Scrollleisten. Verbesserungsbedarf: Die horizontale Scrollleiste ist erst am Ende des gesamten Inhalts erreichbar und sollte wie im AD Kalender am unteren Rand des sichtbaren Planbereichs verfügbar bleiben. | ## B. Schichtkonfiguration und Wunschplanung | ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | |---|---|---|---|---|---| -| B1 | Variable Schichtliste | Als zuständige EB eine Schicht ergänzen, umbenennen, zeitlich ändern, deaktivieren und wieder aktivieren. | Die strukturierte Teamkonfiguration bleibt nach Neuladen erhalten und steuert den Monatsplan. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| B2 | Lücken und Überlappungen | Synthetische Schichten mit einer Lücke und einer zeitlichen Überlappung speichern. | Beide fachlich zulässigen Konfigurationen werden nicht vorschnell abgewiesen und erscheinen nachvollziehbar. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| B3 | Schicht über Mitternacht | Eine Nachtschicht mit Ende am Folgetag konfigurieren und im Monatsplan prüfen. | Die Schicht wird dem Ausgangstag eindeutig zugeordnet und mit ihrem Zeitraum verständlich dargestellt. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| B4 | Eigener Wunsch | Als schichtfähiges Teammitglied einen eigenen Wunsch hinzufügen, Bemerkung speichern und den Wunsch wieder entfernen. | Nur der eigene Eintrag wird verändert; Status und Bemerkung bleiben nach Neuladen erhalten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| B5 | Fremdzuweisung durch EB | Als zuständige EB eine andere schichtfähige Person zuweisen und wieder entfernen. | Die Zuweisung ist möglich und betrifft ausschließlich das gewählte Team und die gewählte Schicht. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| B6 | EB nicht schichtfähig | Versuchen, das reine EB-Testkonto selbst einer Schicht zuzuweisen. | Das EB-Konto wird nicht als schichtfähige Person angeboten beziehungsweise serverseitig abgewiesen. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| B7 | Planstatus | Als EB die angebotenen Statuswechsel einschließlich `planned` und `approved` durchführen; als normales Teammitglied wiederholen. | Nur die EB kann den Status wechseln; unberechtigte Versuche verändern den Plan nicht. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| B1 | Variable Schichtliste | Als zuständige EB eine Schicht ergänzen, umbenennen, zeitlich ändern, deaktivieren und wieder aktivieren. | Die strukturierte Teamkonfiguration bleibt nach Neuladen erhalten und steuert den Monatsplan. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Mit `adc-test-eb-sued` im Team `KaKü` wurde eine Schicht ergänzt, nach Neuladen erhalten, umbenannt, zeitlich geändert, deaktiviert und wieder aktiviert. Der Monatsplan reagierte korrekt. Offene UI-Punkte: Der Speicherbutton für Kommentare liegt rechts außerhalb des sichtbaren Bereichs. Die Mitarbeiterauswahl soll zunächst verborgen bleiben und erst nach Betätigung des `+`-Schalters als kompakte Buttonliste in einem Overlay erscheinen. | +| B2 | Lücken und Überlappungen | Synthetische Schichten mit einer Lücke und einer zeitlichen Überlappung speichern. | Beide fachlich zulässigen Konfigurationen werden nicht vorschnell abgewiesen und erscheinen nachvollziehbar. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Schichtkonfigurationen mit zeitlicher Lücke und mit Überlappung wurden gespeichert und blieben nach Neuladen korrekt erhalten. Zeiten wurden nicht automatisch verändert. Testkonto und Testteam nicht dokumentiert. | +| B3 | Schicht über Mitternacht | Eine Nachtschicht mit Ende am Folgetag konfigurieren und im Monatsplan prüfen. | Die Schicht wird dem Ausgangstag eindeutig zugeordnet und mit ihrem Zeitraum verständlich dargestellt. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Die Nachtschicht wurde gespeichert, blieb nach dem Neuladen erhalten, war eindeutig dem Ausgangstag zugeordnet und als über Mitternacht laufend verständlich dargestellt. Es entstand kein doppelter Eintrag am Folgetag. Präzisierung: Eine gesonderte Darstellung am Folgetag oder Prüfung der Monatsgrenze ist fachlich nicht erforderlich; jede Schicht wird einmal dem Tag ihres Beginns zugeordnet, Monatspläne sind voneinander abgegrenzt. | +| B4 | Eigener Wunsch | Als schichtfähiges Teammitglied einen eigenen Wunsch hinzufügen, Bemerkung speichern und den Wunsch wieder entfernen. | Nur der eigene Eintrag wird verändert; Status und Bemerkung bleiben nach Neuladen erhalten. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Mit `admin` als normalem Assistenz-Teammitglied im Team `KaKü` konnte der eigene Wunsch hinzugefügt, nach Neuladen erhalten und wieder entfernt werden. Fremde Einträge blieben unverändert. Präzisiertes Soll: Normale Teammitglieder dürfen keine Bemerkungen speichern; Bemerkungen sind ausschließlich durch die zuständige EB bearbeitbar. | +| B5 | Fremdzuweisung durch EB | Als zuständige EB eine andere schichtfähige Person zuweisen und wieder entfernen. | Die Zuweisung ist möglich und betrifft ausschließlich das gewählte Team und die gewählte Schicht. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Fremdzuweisung durch eine zuständige EB insgesamt erfolgreich geprüft. Konkrete Testperson, Team, Schicht und Einzelschritte wurden nicht separat dokumentiert. Keine Abweichungen angegeben. | +| B6 | EB nicht schichtfähig | Versuchen, das reine EB-Testkonto selbst einer Schicht zuzuweisen. | Das EB-Konto wird nicht als schichtfähige Person angeboten beziehungsweise serverseitig abgewiesen. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Das reine EB-Konto wurde in der Personenauswahl nicht angeboten; eine Zuweisung über die UI war nicht möglich. Direkte serverseitige Negativprüfung nicht durchgeführt. | +| B7 | Planstatus | Als EB die angebotenen Statuswechsel einschließlich `planned` und `approved` durchführen; als normales Teammitglied wiederholen. | Nur die EB kann den Status wechseln; unberechtigte Versuche verändern den Plan nicht. | [ ] erfolgreich [x] nicht erfolgreich [ ] nicht geprüft | Es gibt derzeit keine Option für den Planstatus. Die zuständige EB muss den Monatsplan mindestens als `planned` und `approved` festschreiben können. Ein als `approved` festgeschriebener Plan darf anschließend nicht mehr verändert werden. Normale Teammitglieder dürfen den Status nicht ändern. | ## C. Team- und Rechteabgrenzung | ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | |---|---|---|---|---|---| -| C1 | Teammitgliedschaft | Mit einem neutralen Konto aus Team A Team A und Team B direkt aufrufen. | Nur der erlaubte Teamkontext ist nutzbar; ein direkter unberechtigter Request auf Team B wird serverseitig abgewiesen. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| C2 | EB-Schnittmenge | Ein Testkonto nur in der EB-Rollengruppe, ein Konto nur im Team und ein Konto in beiden Gruppen vergleichen. | EB-Rechte entstehen nur aus der vorgesehenen Team-und-Rolle-Schnittmenge. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| C3 | Fremdänderung durch normales Mitglied | Als normales Teammitglied eine fremde Zuweisung über UI und direkten API-Aufruf ändern. | Beide Wege werden abgewiesen; der bestehende Eintrag bleibt unverändert. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| C4 | Bereichs- und Teamtrennung | Zwei Teams mit ähnlich benannten neutralen Konten und unterschiedlichen Konfigurationen prüfen. | Zuweisungen, Schichten, Bemerkungen und Rechte werden nicht zwischen Teams vermischt. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| C5 | Demo-Pack-Schutz | Adminbereich öffnen, Installation ohne Bestätigung versuchen und anschließend nur in einer dafür vorgesehenen Testumgebung bestätigen. | Ohne ausdrückliche Bestätigung bleibt die Aktion gesperrt; es werden ausschließlich synthetische Konten und Teams verwendet. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| C1 | Teammitgliedschaft | Mit einem neutralen Konto aus Team A Team A und Team B direkt aufrufen. | Nur der erlaubte Teamkontext ist nutzbar; ein direkter unberechtigter Request auf Team B wird serverseitig abgewiesen. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Der erlaubte Teamkontext war nutzbar; der Zugriff auf ein nicht erlaubtes Team wurde insgesamt erfolgreich als abgegrenzt bewertet. Testkonto, Teams und Einzelprüfungen wurden nicht dokumentiert. | +| C2 | EB-Schnittmenge | Ein Testkonto nur in der EB-Rollengruppe, ein Konto nur im Team und ein Konto in beiden Gruppen vergleichen. | EB-Rechte entstehen nur aus der vorgesehenen Team-und-Rolle-Schnittmenge. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Die vorgesehenen Rechte entstanden nur bei der Kombination aus Teamzugehörigkeit und EB-Rolle. Als Nur-EB-Testkontext wurde `west` angegeben. Weitere Konten und Einzelprüfungen wurden nicht separat dokumentiert. | +| C3 | Fremdänderung durch normales Mitglied | Als normales Teammitglied eine fremde Zuweisung über UI und direkten API-Aufruf ändern. | Beide Wege werden abgewiesen; der bestehende Eintrag bleibt unverändert. | [ ] erfolgreich [ ] nicht erfolgreich [x] nicht geprüft | Mit `admin` im Team `KaKü` war eine fremde Änderung über die UI nicht möglich und der bestehende Eintrag blieb unverändert. Die erforderliche serverseitige Prüfung über einen direkten API-Aufruf wurde nicht durchgeführt. | +| C4 | Bereichs- und Teamtrennung | Zwei Teams mit ähnlich benannten neutralen Konten und unterschiedlichen Konfigurationen prüfen. | Zuweisungen, Schichten, Bemerkungen und Rechte werden nicht zwischen Teams vermischt. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Die Teams `HaHü` und `KaKü` wurden verglichen. Schichten, Zuweisungen, Bemerkungen und Rechte blieben getrennt. Auch nach Team- und Monatswechsel trat keine Vermischung auf. | +| C5 | Demo-Pack-Schutz | Adminbereich öffnen, Installation ohne Bestätigung versuchen und anschließend nur in einer dafür vorgesehenen Testumgebung bestätigen. | Ohne ausdrückliche Bestätigung bleibt die Aktion gesperrt; es werden ausschließlich synthetische Konten und Teams verwendet. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Installation ohne Bestätigung wurde blockiert und erzeugte keine Daten. Nach ausdrücklicher Bestätigung war die Installation möglich. Es wurden ausschließlich synthetische Konten, Teams und Plandaten erzeugt; bestehende Daten blieben unverändert. Eine erneute Installation wurde sicher behandelt. | ## D. Optionale Integrationen und Fehlerzustände | ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | |---|---|---|---|---|---| -| D1 | Betrieb ohne AD Urlaub | AD Urlaub deaktiviert lassen und Monatsplan, Einstellungen und Zuweisungen prüfen. | Der Monatsplan bleibt vollständig nutzbar; fehlende Abwesenheitshinweise blockieren keine Aktion. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| D2 | Betrieb ohne AD Kalender | AD Kalender deaktiviert lassen und dieselben Kernabläufe wiederholen. | Die Assistenzplanung bleibt nutzbar; der fehlende optionale Provider wird nicht als Planfehler behandelt. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| D3 | Optionale Hinweise | Mit aktivem, neutral vorbereitetem Urlaubs- oder Kalenderprovider dessen Hinweise im Monatsplan prüfen. | Hinweise werden read-only dargestellt und verändern weder Teamrechte noch die führenden AdPlaner-Daten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| D4 | Verständliche Validierung | Leere Namen, unvollständige Zeiten und ungültige Eingaben in Einstellungen und Planung versuchen. | Fehler werden am richtigen Kontext verständlich angezeigt; gültige bestehende Daten bleiben erhalten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | -| D5 | Datensparsame Abnahme | Formular und Screenshots prüfen. | Es wurden nur synthetische Team-, Dienst- und Kontodaten dokumentiert. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| D1 | Betrieb ohne AD Urlaub | AD Urlaub deaktiviert lassen und Monatsplan, Einstellungen und Zuweisungen prüfen. | Der Monatsplan bleibt vollständig nutzbar; fehlende Abwesenheitshinweise blockieren keine Aktion. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Bei deaktiviertem AD Urlaub wurden Monatsplan, Team- und Monatswechsel, Einstellungen, eigene Wünsche und Fremdzuweisungen vollständig genutzt. Die fehlende Integration erzeugte weder Fehler noch blockierte Aktionen. | +| D2 | Betrieb ohne AD Kalender | AD Kalender deaktiviert lassen und dieselben Kernabläufe wiederholen. | Die Assistenzplanung bleibt nutzbar; der fehlende optionale Provider wird nicht als Planfehler behandelt. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Bei deaktiviertem AD Kalender blieben Monatsplan, Team- und Monatswechsel, Schichten, Einstellungen, eigene Wünsche und Fremdzuweisungen vollständig nutzbar. Der fehlende Kalenderprovider erzeugte weder Fehler noch blockierte Aktionen. Zusätzliche Beobachtung: Vorhandener Urlaub hatte zu diesem Zeitpunkt noch keine sichtbare Auswirkung auf AdPlaner; dies wurde in D3 bewertet. | +| D3 | Optionale Hinweise | Mit aktivem, neutral vorbereitetem Urlaubs- oder Kalenderprovider dessen Hinweise im Monatsplan prüfen. | Hinweise werden read-only dargestellt und verändern weder Teamrechte noch die führenden AdPlaner-Daten. | [ ] erfolgreich [x] nicht erfolgreich [ ] nicht geprüft | AD Urlaub und AD Kalender waren aktiv. Ein vorhandener synthetischer Urlaub wurde im Monatsplan nicht als Hinweis dargestellt; die Read-only-Eigenschaft war daher nicht prüfbar. Ein Kalenderhinweis war nicht vorhanden. Teamrechte und führende AdPlaner-Daten wurden nicht verändert. | +| D4 | Verständliche Validierung | Leere Namen, unvollständige Zeiten und ungültige Eingaben in Einstellungen und Planung versuchen. | Fehler werden am richtigen Kontext verständlich angezeigt; gültige bestehende Daten bleiben erhalten. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Leere Namen, unvollständige Zeiten und weitere ungültige Eingaben wurden verständlich und am richtigen Kontext abgewiesen. Bestehende gültige Daten blieben erhalten; die Eingaben konnten unmittelbar korrigiert werden. Fehlermeldungen erscheinen vor einem etwaigen Neuladen, sodass kein kommentarloser Reload erfolgt. | +| D5 | Datensparsame Abnahme | Formular und Screenshots prüfen. | Es wurden nur synthetische Team-, Dienst- und Kontodaten dokumentiert. | [x] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | Ausschließlich neutrale Testkonten sowie synthetische oder freigegebene Teams und Schichten dokumentiert. Keine personenbezogenen Bemerkungen, Gesundheits- oder Urlaubsdetails, Zugangsdaten, Tokens oder unzulässigen Echtdaten in Screenshots. | ## Abschlussentscheidung | Feld | Eintrag | |---|---| -| Anzahl erfolgreich | | -| Anzahl nicht erfolgreich | | -| Anzahl nicht geprüft | | -| Kritische Abweichungen / Ticketreferenzen | | -| Erneute Prüfung erforderlich bis | | -| Gesamtentscheidung | [ ] abgenommen [ ] mit Auflagen abgenommen [ ] nicht abgenommen | -| Begründung der Gesamtentscheidung | | -| Name / Datum | | +| Anzahl erfolgreich | 20 | +| Anzahl nicht erfolgreich | 2 | +| Anzahl nicht geprüft | 1 | +| Kritische Abweichungen / Ticketreferenzen | B7: Planstatus `planned`/`approved` und Änderungssperre fehlen.
D3: Optionale Urlaubs- und Kalenderhinweise werden nicht wie vorgesehen read-only dargestellt.
C3: Direkte serverseitige Negativprüfung einer Fremdänderung steht aus.
Weitere UI-Punkte: Monatsnavigation A4, sichtbare horizontale Scrollleiste A6, Kommentar-Speicherbutton und Mitarbeiterauswahl B1. | +| Erneute Prüfung erforderlich bis | Vor Freigabe einer abnahmefähigen Version; kein konkretes Datum festgelegt | +| Gesamtentscheidung | [ ] abgenommen [ ] mit Auflagen abgenommen [x] nicht abgenommen | +| Begründung der Gesamtentscheidung | Die zentrale Planfreigabe einschließlich Statusverwaltung und Änderungssperre fehlt. Außerdem werden optionale Urlaubs- beziehungsweise Kalenderhinweise nicht wie vorgesehen dargestellt. C3 ist serverseitig noch nicht vollständig geprüft. | +| Name / Datum | Simon / 02.08.2026 | + +## Zu bearbeitende Punkte + +### Hohe Priorität + +1. **B7 – Planstatus ergänzen** + - Status `planned` und `approved` implementieren. + - Nur die zuständige EB darf den Status ändern. + - Ein `approved`-Plan muss gegen Änderungen an Schichten, Zuweisungen, Wünschen und Bemerkungen gesperrt sein. + - Status muss nach Neuladen erhalten bleiben. + - Ein ausdrücklicher, berechtigter Entsperr- oder Rücksetzweg muss festgelegt werden. + +2. **D3 – Optionale Hinweise darstellen** + - Vorhandenen Urlaub einer schichtfähigen Person am betroffenen Tag im Monatsplan sichtbar machen. + - Hinweise ausschließlich read-only darstellen. + - Optional vorhandene Kalenderhinweise ebenfalls read-only anzeigen. + - Hinweise dürfen weder Teamrechte noch führende AdPlaner-Daten verändern. + - Die fachliche Wirkung auf Wünsche und Zuweisungen ist noch ausdrücklich festzulegen. + +### Mittlere Priorität + +3. **A6 – Horizontale Scrollleiste erreichbar halten** + - Horizontale Scrollleiste am unteren Rand des sichtbaren Plan-Viewports bereitstellen. + - Umsetzung am Verhalten des AD Kalenders orientieren. + +4. **B1 – Kommentar-Speicherbutton im sichtbaren Bereich halten** + - Speicheraktion ohne horizontales Scrollen erreichbar machen. + +5. **B1 – Mitarbeiterauswahl verdichten** + - Mitarbeiterauswahl zunächst verbergen. + - Erst nach Betätigung des `+`-Schalters als kompakte Buttonliste in einem Overlay öffnen. + +### Niedrige Priorität / Nachprüfung + +6. **A4 – Monatsnavigation ergänzen** + - Schalter für vorherigen und nächsten Monat ergänzen. + +7. **B4 – Prüfkriterium und Dokumentation korrigieren** + - Normale Teammitglieder dürfen eigene Wünsche hinzufügen und entfernen. + - Bemerkungen bleiben ausschließlich durch die zuständige EB bearbeitbar. + +8. **C3 – Serverseitige Rechteprüfung nachholen** + - Fremdänderung durch ein normales Teammitglied über einen direkten API-Aufruf versuchen. + - Verifizieren, dass der Server die Änderung abweist und der bestehende Eintrag unverändert bleibt. \ No newline at end of file From 040b44f2742bc02df266417044f20614bf88e32a Mon Sep 17 00:00:00 2001 From: filzmann Date: Sat, 8 Aug 2026 19:11:20 +0200 Subject: [PATCH 06/10] Add controlled AdPlaner month approval --- CHANGELOG.md | 7 + README.md | 2 + ROADMAP.md | 2 - appinfo/info.xml | 2 +- appinfo/routes.php | 1 + css/style.css | 45 ++++++ js/components/candidate-chip.js | 4 +- js/components/month-plan.js | 45 ++++-- js/components/plan-chrome.js | 11 ++ js/modules/plan-app.js | 1 + js/repositories/plan-repository.js | 6 + lib/Controller/ApiController.php | 19 +++ .../Version000003Date202608080001.php | 33 +++++ lib/Repository/ShiftPlanRepository.php | 47 ++++++ lib/Service/PlanningHintService.php | 94 ++++++++++++ lib/Service/ScheduleService.php | 50 ++++++- lib/Store/ShiftPlanStore.php | 20 +++ templates/index.php | 6 +- .../ControllerAttributeSmokeTest.php | 1 + tests/MigrationContractTest.php | 24 +++ tests/Service/MonthPlanStatusServiceTest.php | 138 ++++++++++++++++++ tests/Service/PlanningHintServiceTest.php | 68 +++++++++ tests/Service/ScheduleServiceSmokeTest.php | 24 ++- tests/Ui/LayoutSmokeTest.php | 22 +++ tests/access-http-smoke.sh | 76 ++++++++++ tests/access-matrix-ddev-smoke.sh | 42 ++++++ tests/integration/MonthPlanStatusSmoke.php | 44 ++++++ tests/js/main-workflow-smoke.js | 24 +++ tests/js/month-plan-smoke.js | 28 ++++ tests/js/plan-repository-smoke.js | 5 +- 30 files changed, 870 insertions(+), 21 deletions(-) create mode 100644 lib/Migration/Version000003Date202608080001.php create mode 100644 lib/Service/PlanningHintService.php create mode 100644 tests/MigrationContractTest.php create mode 100644 tests/Service/MonthPlanStatusServiceTest.php create mode 100644 tests/Service/PlanningHintServiceTest.php create mode 100644 tests/Ui/LayoutSmokeTest.php create mode 100755 tests/access-http-smoke.sh create mode 100755 tests/access-matrix-ddev-smoke.sh create mode 100644 tests/integration/MonthPlanStatusSmoke.php diff --git a/CHANGELOG.md b/CHANGELOG.md index de4616b..ce2e159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.4.0-rc.1 + +- Kontrollierte Monatsplanstatus `draft`, `planned` und `approved` ergänzt; nur die zuständige Einsatzbegleitung darf wechseln und genehmigte Pläne sind gegen Änderungen gesperrt. +- Optionale, datensparsame Urlaubs- und Kalenderhinweise über die öffentlichen LocalBase-Verträge eingebunden; fehlende Provider bleiben ein gültiger Standalone-Zustand. +- Direkte Navigation zum vorherigen und nächsten Monat sowie sichtbarer Tabellen-Scrollbereich und fixierte Bemerkungsspalte ergänzt. +- Additive Monatsplanstatus-Migration sowie automatisierte Service-, UI-, Migrations-, Datenbank- und DDEV-Rechteprüfungen ergänzt. + ## 0.3.0-rc.1 - Eigenständige Navigation ohne OrgSuite ergänzt. diff --git a/README.md b/README.md index 0907a98..2de183f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ AdPlaner funktioniert einzeln; optionale Abwesenheits- oder Kalenderhinweise ent Assistenzteams werden aus den zentral konfigurierten Nextcloud-Gruppen abgeleitet. Teambezogene Schichtkonfigurationen werden durch berechtigte Einsatzbegleitungen gepflegt. +Die zuständige Einsatzbegleitung führt Monatspläne kontrolliert von `draft` über `planned` nach `approved`. Genehmigte Pläne sind bis zu einer ausdrücklichen Rücknahme gegen Wünsche, Zuweisungen und Bemerkungsänderungen gesperrt. Optionale Urlaubs- und Kalenderprovider liefern ausschließlich datensparsame, schreibgeschützte Planungshinweise. + ## Roadmap Geplante Erweiterungen und offene Produktentscheidungen stehen in der [Roadmap](ROADMAP.md). diff --git a/ROADMAP.md b/ROADMAP.md index c2e9b05..d634cec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,11 +31,9 @@ Status: als l10n-Pilot geeignet - Persönliche Monatsansicht „Alle meine Einsätze“ mit PDF-Export und optionaler Verbindung zu gängigen Kalendern. - Benachrichtigungen für relevante Planungs- und Statusänderungen. -- Fachlich eindeutige Festschreibung eines Dienstplans. - Teambezogene Konfigurierbarkeit nur dort erweitern, wo konkrete Teams unterschiedliche Regeln benötigen. ## Vor der Umsetzung zu klären - Exportformate, Zielsysteme und Datenschutzumfang. - Benachrichtigungskanäle, Empfänger*innen und auslösende Ereignisse. -- Bedeutung, Rechte und Rückbau einer Festschreibung sowie der Umgang mit späteren Änderungen. diff --git a/appinfo/info.xml b/appinfo/info.xml index 8ba1330..7976abe 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ Assistenz Dienstplanung Wunschdienstplanung für Assistenzteams. Verwaltet monatliche Wunschdienstpläne für Assistenzteams auf Basis dynamischer Nextcloud-Gruppen. - 0.3.0-rc.2 + 0.4.0-rc.1 agpl Simon https://github.com/Filzmann/ad-suite diff --git a/appinfo/routes.php b/appinfo/routes.php index 0506afe..fa938bc 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -6,6 +6,7 @@ ['name' => 'api#state', 'url' => '/api/state', 'verb' => 'GET'], ['name' => 'api#monthPlan', 'url' => '/api/teams/{teamCode}/months/{month}', 'verb' => 'GET'], + ['name' => 'api#transitionMonthStatus', 'url' => '/api/teams/{teamCode}/months/{month}/status', 'verb' => 'POST'], ['name' => 'api#saveTeamSettings', 'url' => '/api/teams/{teamCode}/settings', 'verb' => 'POST'], ['name' => 'api#saveDayNote', 'url' => '/api/teams/{teamCode}/months/{month}/days/{workDate}/note', 'verb' => 'POST'], ['name' => 'api#addShiftCandidate', 'url' => '/api/teams/{teamCode}/months/{month}/slots/{slotId}/candidates', 'verb' => 'POST'], diff --git a/css/style.css b/css/style.css index 3e5ff86..cef6af0 100644 --- a/css/style.css +++ b/css/style.css @@ -26,6 +26,8 @@ .adp-controls, .adp-tabs, .adp-section-head, +.adp-plan-meta, +.adp-plan-status, .adp-cell-actions { display: flex; align-items: center; @@ -101,6 +103,7 @@ .adp-table-wrap { overflow: auto; max-width: 100%; + max-height: calc(100vh - 260px); } .adp-table { @@ -133,6 +136,18 @@ border-left: 1px solid var(--color-border); } +.adp-month-table th:last-child, +.adp-month-table td:last-child { + position: sticky; + right: 0; + z-index: 2; + box-shadow: -1px 0 0 var(--color-border); +} + +.adp-month-table thead th:last-child { + z-index: 4; +} + .adp-table small, .adp-day small { display: block; @@ -156,6 +171,25 @@ color: var(--color-main-text); } +.adp-day-hints { + display: grid; + gap: 3px; + margin-top: 6px; +} + +.adp-day .adp-hint { + display: inline-flex; + width: max-content; + max-width: 160px; + border-radius: 6px; + padding: 1px 5px; + background: var(--color-background-hover); + color: var(--color-main-text); + font-size: .8rem; + font-weight: 600; + white-space: normal; +} + .adp-candidates { display: flex; align-items: flex-start; @@ -199,6 +233,17 @@ text-align: center; } +.adp-month-control { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.adp-month-control .adp-icon-button { + min-height: 34px; + margin: 0; +} + .adp-assignment-control, .adp-assignment-picker { display: inline-flex; diff --git a/js/components/candidate-chip.js b/js/components/candidate-chip.js index b937c79..b1e9852 100644 --- a/js/components/candidate-chip.js +++ b/js/components/candidate-chip.js @@ -1,8 +1,8 @@ (function() { const { esc } = window.ADPlaner.ui; - function render(candidate, canCoordinate, slotId) { - const removable = canCoordinate || candidate.isSelf; + function render(candidate, canCoordinate, slotId, mutable = true) { + const removable = mutable && (canCoordinate || candidate.isSelf); return ` diff --git a/js/components/month-plan.js b/js/components/month-plan.js index 3aef0f5..503928b 100644 --- a/js/components/month-plan.js +++ b/js/components/month-plan.js @@ -12,12 +12,17 @@ const team = plan.team; const segments = plan.segments || []; const canCoordinate = !!team.canCoordinate; + const status = plan.status || 'draft'; + const mutable = status !== 'approved'; return `

${esc(team.displayName || team.code)} - ${esc(plan.month)}

- ${team.settings && team.settings.meetingDay ? `Treffen ${esc(dateShort(team.settings.meetingDay))}` : ''} +
+ ${team.settings && team.settings.meetingDay ? `Treffen ${esc(dateShort(team.settings.meetingDay))}` : ''} + ${renderStatus(status, canCoordinate)} +
@@ -29,7 +34,7 @@ - ${(plan.days || []).map(day => dayRow(day, segments, team, currentUser, canCoordinate)).join('')} + ${(plan.days || []).map(day => dayRow(day, segments, team, currentUser, canCoordinate, mutable)).join('')}
@@ -37,7 +42,20 @@ `; } - function dayRow(day, segments, team, currentUser, canCoordinate) { + function renderStatus(status, canCoordinate) { + const labels = { draft: 'Entwurf', planned: 'Geplant', approved: 'Genehmigt' }; + const actions = { + draft: [['planned', 'Als geplant festschreiben']], + planned: [['draft', 'Auf Entwurf zurücksetzen'], ['approved', 'Genehmigen']], + approved: [['planned', 'Genehmigung aufheben']], + }; + return `
+ Status: ${esc(labels[status] || status)} + ${canCoordinate ? (actions[status] || []).map(([target, label]) => ``).join('') : ''} +
`; + } + + function dayRow(day, segments, team, currentUser, canCoordinate, mutable) { const slotsByKey = {}; (day.slots || []).forEach(slot => { slotsByKey[slot.segmentKey] = slot; @@ -45,14 +63,21 @@ return ` - ${dayHeader(day)}${esc(dateShort(day.date))} - ${segments.map(segment => slotCell(slotsByKey[segment.key], team, currentUser, canCoordinate)).join('')} - ${renderDayNoteControl(day, canCoordinate)} + ${dayHeader(day)}${esc(dateShort(day.date))}${renderHints(day.hints || [])} + ${segments.map(segment => slotCell(slotsByKey[segment.key], team, currentUser, canCoordinate, mutable)).join('')} + ${renderDayNoteControl(day, canCoordinate && mutable)} `; } - function slotCell(slot, team, currentUser, canCoordinate) { + function renderHints(hints) { + if (!hints.length) return ''; + return `
${hints.map(hint => + `${esc(hint.marker || '•')} ${esc(hint.displayName || hint.employeeUid || '')}` + ).join('')}
`; + } + + function slotCell(slot, team, currentUser, canCoordinate, mutable) { if (!slot) { return ''; } @@ -60,18 +85,18 @@ const candidates = slot.candidates || []; const selfUid = currentUser && currentUser.uid ? currentUser.uid : ''; const hasSelf = candidates.some(candidate => candidate.uid === selfUid); - const selfAction = !canCoordinate && !hasSelf + const selfAction = mutable && !canCoordinate && !hasSelf ? `` : ''; return `
- ${candidates.map(candidate => renderCandidateChip(candidate, canCoordinate, slot.id)).join('')} + ${candidates.map(candidate => renderCandidateChip(candidate, canCoordinate, slot.id, mutable)).join('')}
${selfAction} - ${canCoordinate ? renderAssignmentControl(slot, team, candidates) : ''} + ${canCoordinate && mutable ? renderAssignmentControl(slot, team, candidates) : ''}
`; diff --git a/js/components/plan-chrome.js b/js/components/plan-chrome.js index 343fbac..2dc4786 100644 --- a/js/components/plan-chrome.js +++ b/js/components/plan-chrome.js @@ -11,11 +11,15 @@ this.onViewChange = options.onViewChange; this.teamSelect = this.byId('team-select'); this.monthInput = this.byId('month-input'); + this.monthPrevious = this.byId('month-prev'); + this.monthNext = this.byId('month-next'); this.panel = this.byId('adp-panel'); this.tabs = document.querySelector('.adp-tabs'); this.tabButtons = Array.from(document.querySelectorAll('.adp-tab')); this.teamSelect.addEventListener('change', event => this.onTeamChange(event.target.value)); this.monthInput.addEventListener('change', event => this.onMonthChange(event.target.value)); + this.monthPrevious.addEventListener('click', () => this.onMonthChange(this.offsetMonth(this.monthInput.value, -1))); + this.monthNext.addEventListener('click', () => this.onMonthChange(this.offsetMonth(this.monthInput.value, 1))); this.tabs.addEventListener('click', event => { const button = event.target instanceof Element ? event.target.closest('button[data-view]') : null; if (button) return this.onViewChange(button.dataset.view || 'month'); @@ -37,6 +41,13 @@ }); } + offsetMonth(month, delta) { + const match = /^(\d{4})-(\d{2})$/.exec(month || ''); + if (!match) return month; + const value = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1 + delta, 1)); + return `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, '0')}`; + } + render(state) { this.teamSelect.innerHTML = state.teams.map(team => { const selected = team.code === state.selectedTeamCode ? ' selected' : ''; diff --git a/js/modules/plan-app.js b/js/modules/plan-app.js index 53b43d2..415a56a 100644 --- a/js/modules/plan-app.js +++ b/js/modules/plan-app.js @@ -83,6 +83,7 @@ if (!select || select.disabled || !select.value) return; await this.repository.addSelected(team, month, slot, select.value); } else if (action === 'remove-candidate') await this.repository.removeCandidate(team, month, slot, button.dataset.targetUid || ''); + else if (action === 'transition-status') await this.repository.transitionStatus(team, month, button.dataset.targetStatus || ''); else if (action === 'save-note') { const textarea = this.panel.panel.querySelector(`textarea[data-note-date="${CSS.escape(button.dataset.date)}"]`); await this.repository.saveDayNote(team, month, button.dataset.date, textarea ? textarea.value : ''); diff --git a/js/repositories/plan-repository.js b/js/repositories/plan-repository.js index ba239e3..9d6e267 100644 --- a/js/repositories/plan-repository.js +++ b/js/repositories/plan-repository.js @@ -38,6 +38,12 @@ }); } + transitionStatus(teamCode, month, targetStatus) { + return this.post(this.teamPath(teamCode) + '/months/' + this.encode(month) + '/status', { + targetStatus + }); + } + saveSettings(teamCode, displayName, meetingDay, shifts) { return this.post(this.teamPath(teamCode) + '/settings', { displayName, diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index b103b91..84d4a05 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -55,6 +55,25 @@ public function monthPlan(string $teamCode, string $month): DataResponse { }, [$this->logger, 'error'], 'month_plan', ['team_code' => $teamCode, 'month' => $month]); } + #[NoAdminRequired] + public function transitionMonthStatus(string $teamCode, string $month, string $targetStatus): DataResponse { + return $this->responder->respond(function () use ($teamCode, $month, $targetStatus): array { + $team = $this->teamAccess->assertCanCoordinate($teamCode); + $status = $this->scheduleService->transitionMonthStatus( + $team, + $month, + $targetStatus, + $this->teamAccess->currentUserId() + ); + + return ['ok' => true, 'status' => $status]; + }, [$this->logger, 'error'], 'transition_month_status', [ + 'team_code' => $teamCode, + 'month' => $month, + 'target_status' => $targetStatus, + ]); + } + #[NoAdminRequired] public function saveTeamSettings( string $teamCode, diff --git a/lib/Migration/Version000003Date202608080001.php b/lib/Migration/Version000003Date202608080001.php new file mode 100644 index 0000000..f022266 --- /dev/null +++ b/lib/Migration/Version000003Date202608080001.php @@ -0,0 +1,33 @@ +hasTable('adp_month_plans')) { + return $schema; + } + + $table = $schema->createTable('adp_month_plans'); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('team_code', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('plan_month', Types::STRING, ['notnull' => true, 'length' => 7]); + $table->addColumn('status', Types::STRING, ['notnull' => true, 'length' => 16, 'default' => 'draft']); + $table->addColumn('updated_by_uid', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('updated_at', Types::DATETIME, ['notnull' => true]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['team_code', 'plan_month'], 'adp_month_plan_unique'); + + return $schema; + } +} diff --git a/lib/Repository/ShiftPlanRepository.php b/lib/Repository/ShiftPlanRepository.php index 06d70a3..504307b 100644 --- a/lib/Repository/ShiftPlanRepository.php +++ b/lib/Repository/ShiftPlanRepository.php @@ -175,6 +175,53 @@ public function saveDayNote(string $teamCode, string $workDate, string $note, st $qb->executeStatement(); } + public function monthStatus(string $teamCode, string $month): ?string { + $qb = $this->db->getQueryBuilder(); + $qb->select('status') + ->from('adp_month_plans') + ->where($qb->expr()->eq('team_code', $qb->createNamedParameter($teamCode))) + ->andWhere($qb->expr()->eq('plan_month', $qb->createNamedParameter($month))); + $row = $qb->executeQuery()->fetchAssociative(); + + return $row === false ? null : (string)$row['status']; + } + + public function transitionMonthStatus( + string $teamCode, + string $month, + string $expectedStatus, + string $targetStatus, + string $updatedByUid + ): bool { + $now = new DateTimeImmutable(); + if ($this->monthStatus($teamCode, $month) === null) { + if ($expectedStatus !== 'draft') { + return false; + } + $qb = $this->db->getQueryBuilder(); + $qb->insert('adp_month_plans')->values([ + 'team_code' => $qb->createNamedParameter($teamCode), + 'plan_month' => $qb->createNamedParameter($month), + 'status' => $qb->createNamedParameter($targetStatus), + 'updated_by_uid' => $qb->createNamedParameter($updatedByUid), + 'updated_at' => $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + ]); + + return $qb->executeStatement() === 1; + } + + $qb = $this->db->getQueryBuilder(); + $qb->update('adp_month_plans') + ->set('status', $qb->createNamedParameter($targetStatus)) + ->set('updated_by_uid', $qb->createNamedParameter($updatedByUid)) + ->set('updated_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_IMMUTABLE)) + ->where($qb->expr()->eq('team_code', $qb->createNamedParameter($teamCode))) + ->andWhere($qb->expr()->eq('plan_month', $qb->createNamedParameter($month))) + ->andWhere($qb->expr()->eq('status', $qb->createNamedParameter($expectedStatus))); + + return $qb->executeStatement() === 1; + } + private function candidateExists(int $slotId, string $assistantUid): bool { $qb = $this->db->getQueryBuilder(); $qb->select('id') diff --git a/lib/Service/PlanningHintService.php b/lib/Service/PlanningHintService.php new file mode 100644 index 0000000..c31327f --- /dev/null +++ b/lib/Service/PlanningHintService.php @@ -0,0 +1,94 @@ + $employeeUids + * @return array> + */ + public function forMonth(string $month, array $employeeUids): array { + if (preg_match('/^(\d{4})-(\d{2})$/', $month, $matches) !== 1 + || !checkdate((int)$matches[2], 1, (int)$matches[1])) { + throw new \InvalidArgumentException('Ungültiger Planungsmonat.'); + } + $employeeUids = array_values(array_unique(array_filter(array_map('strval', $employeeUids)))); + if ($employeeUids === []) { + return []; + } + + $utc = new DateTimeZone('UTC'); + $start = new DateTimeImmutable($month . '-01 00:00:00', $utc); + $end = $start->modify('+1 month'); + $hints = []; + + $absenceEvent = new AbsenceQueryEvent($start, $end, $employeeUids); + $this->events->dispatchTyped($absenceEvent); + foreach ($absenceEvent->absences() as $absence) { + $payload = $absence->toArray(); + $this->appendForDays( + $hints, + new DateTimeImmutable($payload['start']), + new DateTimeImmutable($payload['end']), + $start, + $end, + [ + 'employeeUid' => $payload['employeeUid'], + 'type' => 'absence', + 'marker' => $payload['marker'], + 'label' => 'Urlaub', + 'blocks' => false, + ] + ); + } + + foreach ($employeeUids as $employeeUid) { + $conflictEvent = new ScheduleConflictQueryEvent($employeeUid, $start, $end); + $this->events->dispatchTyped($conflictEvent); + foreach ($conflictEvent->conflicts() as $conflict) { + $payload = $conflict->toArray(); + $this->appendForDays( + $hints, + new DateTimeImmutable($payload['start']), + new DateTimeImmutable($payload['end']), + $start, + $end, + [ + 'employeeUid' => $employeeUid, + 'type' => 'calendar', + 'marker' => 'K', + 'label' => $payload['type'] === 'shift' ? 'Dienst' : 'Termin', + 'blocks' => false, + ] + ); + } + } + + foreach ($hints as &$dayHints) { + usort($dayHints, static fn(array $left, array $right): int => [$left['employeeUid'], $left['marker']] <=> [$right['employeeUid'], $right['marker']]); + } + unset($dayHints); + + return $hints; + } + + private function appendForDays(array &$hints, DateTimeImmutable $hintStart, DateTimeImmutable $hintEnd, DateTimeImmutable $monthStart, DateTimeImmutable $monthEnd, array $hint): void { + $cursor = $hintStart < $monthStart ? $monthStart : $hintStart; + $limit = $hintEnd > $monthEnd ? $monthEnd : $hintEnd; + $cursor = $cursor->setTime(0, 0); + while ($cursor < $limit) { + $hints[$cursor->format('Y-m-d')][] = $hint; + $cursor = $cursor->modify('+1 day'); + } + } +} diff --git a/lib/Service/ScheduleService.php b/lib/Service/ScheduleService.php index d4c969b..8aafe3d 100644 --- a/lib/Service/ScheduleService.php +++ b/lib/Service/ScheduleService.php @@ -10,16 +10,24 @@ use OCA\AdPlaner\Store\ShiftPlanStore; class ScheduleService { + private const STATUS_DRAFT = 'draft'; + private const STATUS_PLANNED = 'planned'; + private const STATUS_APPROVED = 'approved'; + public function __construct( private ShiftPlanStore $store, private ShiftConfigService $shiftConfig, - private TeamAccessService $teamAccess + private TeamAccessService $teamAccess, + private PlanningHintService $planningHints ) { } public function monthPlan(Team $team, string $month, string $currentUid): array { $month = $this->shiftConfig->normalizeMonth($month); - $this->ensureMonthSlots($team, $month); + $status = $this->store->monthStatus($team->code, $month); + if ($status !== self::STATUS_APPROVED) { + $this->ensureMonthSlots($team, $month); + } $slots = $this->store->slotsForMonth($team->code, $month); $enabledSlots = array_values(array_filter($slots, static fn(ShiftSlot $slot): bool => $slot->enabled)); @@ -27,6 +35,7 @@ public function monthPlan(Team $team, string $month, string $currentUid): array $assistantLabels = $team->assistantLabelMap(); $assignableUids = $team->assignableAssistantUidMap(); $notes = $this->store->dayNotesForMonth($team->code, $month); + $hints = $this->planningHints->forMonth($month, array_keys($assignableUids)); $slotsByDate = []; foreach ($enabledSlots as $slot) { @@ -47,12 +56,17 @@ public function monthPlan(Team $team, string $month, string $currentUid): array 'weekday' => $day['weekday'], 'slots' => $slotsByDate[$date] ?? [], 'note' => isset($notes[$date]) ? $notes[$date]->note : '', + 'hints' => array_map(static function (array $hint) use ($assistantLabels): array { + $hint['displayName'] = $assistantLabels[$hint['employeeUid']] ?? $hint['employeeUid']; + return $hint; + }, $hints[$date] ?? []), ]; } return [ 'team' => $team->toArray(), 'month' => $month, + 'status' => $status, 'segments' => array_values(array_filter($this->shiftConfig->segments($team->settings), static fn(array $segment): bool => $segment['enabled'])), 'days' => $days, ]; @@ -60,6 +74,7 @@ public function monthPlan(Team $team, string $month, string $currentUid): array public function addCandidate(Team $team, string $month, int $slotId, string $targetUid, string $currentUid): void { $month = $this->shiftConfig->normalizeMonth($month); + $this->assertMonthMutable($team->code, $month); $slot = $this->requireSlot($slotId, $team->code, $month); if ($targetUid === '') { if ($team->isEb) { @@ -77,6 +92,7 @@ public function addCandidate(Team $team, string $month, int $slotId, string $tar public function removeCandidate(Team $team, string $month, int $slotId, string $targetUid, string $currentUid): void { $month = $this->shiftConfig->normalizeMonth($month); + $this->assertMonthMutable($team->code, $month); $slot = $this->requireSlot($slotId, $team->code, $month); $targetUid = $targetUid === '' ? $currentUid : $targetUid; @@ -92,9 +108,33 @@ public function saveDayNote(Team $team, string $workDate, string $note, string $ } $workDate = $this->shiftConfig->normalizeDate($workDate); + $this->assertMonthMutable($team->code, substr($workDate, 0, 7)); $this->store->saveDayNote($team->code, $workDate, trim($note), $currentUid); } + public function transitionMonthStatus(Team $team, string $month, string $targetStatus, string $currentUid): string { + if (!$team->isEb) { + throw new \DomainException('Nur die Einsatzbegleitung darf den Planstatus ändern.'); + } + + $month = $this->shiftConfig->normalizeMonth($month); + $targetStatus = strtolower(trim($targetStatus)); + $currentStatus = $this->store->monthStatus($team->code, $month); + $allowedTargets = [ + self::STATUS_DRAFT => [self::STATUS_PLANNED], + self::STATUS_PLANNED => [self::STATUS_DRAFT, self::STATUS_APPROVED], + self::STATUS_APPROVED => [self::STATUS_PLANNED], + ]; + if (!in_array($targetStatus, $allowedTargets[$currentStatus] ?? [], true)) { + throw new \DomainException('Dieser Planstatuswechsel ist nicht erlaubt.'); + } + if (!$this->store->transitionMonthStatus($team->code, $month, $currentStatus, $targetStatus, $currentUid)) { + throw new \DomainException('Der Planstatus wurde zwischenzeitlich geändert. Bitte neu laden.'); + } + + return $targetStatus; + } + private function ensureMonthSlots(Team $team, string $month): void { $existingSlots = $this->store->slotsForMonth($team->code, $month); $existing = []; @@ -188,4 +228,10 @@ private function assertAssignableAssistantInTeam(Team $team, string $assistantUi throw new \DomainException('Einsatzbegleitungen können keiner Schicht zugeteilt werden.'); } } + + private function assertMonthMutable(string $teamCode, string $month): void { + if ($this->store->monthStatus($teamCode, $month) === self::STATUS_APPROVED) { + throw new \DomainException('Der genehmigte Monatsplan ist gegen Änderungen gesperrt.'); + } + } } diff --git a/lib/Store/ShiftPlanStore.php b/lib/Store/ShiftPlanStore.php index f347380..8f3ef48 100644 --- a/lib/Store/ShiftPlanStore.php +++ b/lib/Store/ShiftPlanStore.php @@ -45,6 +45,26 @@ public function dayNotesForMonth(string $teamCode, string $month): array { return $notes; } + public function monthStatus(string $teamCode, string $month): string { + return $this->repository->monthStatus($teamCode, $month) ?? 'draft'; + } + + public function transitionMonthStatus( + string $teamCode, + string $month, + string $expectedStatus, + string $targetStatus, + string $updatedByUid + ): bool { + return $this->repository->transitionMonthStatus( + $teamCode, + $month, + $expectedStatus, + $targetStatus, + $updatedByUid + ); + } + public function insertSlot( string $teamCode, string $month, diff --git a/templates/index.php b/templates/index.php index d23ea3c..eb3292e 100644 --- a/templates/index.php +++ b/templates/index.php @@ -37,7 +37,11 @@ diff --git a/tests/Controller/ControllerAttributeSmokeTest.php b/tests/Controller/ControllerAttributeSmokeTest.php index 2327942..de8883d 100644 --- a/tests/Controller/ControllerAttributeSmokeTest.php +++ b/tests/Controller/ControllerAttributeSmokeTest.php @@ -58,6 +58,7 @@ final class Application { 'saveDayNote', 'addShiftCandidate', 'removeShiftCandidate', + 'transitionMonthStatus', ]; foreach ($apiActions as $action) { diff --git a/tests/MigrationContractTest.php b/tests/MigrationContractTest.php new file mode 100644 index 0000000..67c5337 --- /dev/null +++ b/tests/MigrationContractTest.php @@ -0,0 +1,24 @@ + 'draft'")) { + throw new RuntimeException('Bestehende Monatspläne erhalten keinen sicheren Entwurfsstatus.'); +} + +echo "AdPlaner month plan migration contract test passed\n"; diff --git a/tests/Service/MonthPlanStatusServiceTest.php b/tests/Service/MonthPlanStatusServiceTest.php new file mode 100644 index 0000000..fecf9cd --- /dev/null +++ b/tests/Service/MonthPlanStatusServiceTest.php @@ -0,0 +1,138 @@ + */ + public array $statuses = []; + public array $added = []; + public array $updatedSlots = []; + + public function __construct() {} + + public function monthStatus(string $teamCode, string $month): string { + return $this->statuses[$teamCode . '|' . $month] ?? 'draft'; + } + + public function transitionMonthStatus(string $teamCode, string $month, string $expectedStatus, string $targetStatus, string $updatedByUid): bool { + $key = $teamCode . '|' . $month; + if ($this->monthStatus($teamCode, $month) !== $expectedStatus) { + return false; + } + $this->statuses[$key] = $targetStatus; + return true; + } + + public function slotForMonth(int $slotId, string $teamCode, string $month): ?ShiftSlot { + return new ShiftSlot($slotId, $teamCode, $month, $month . '-01', 'early', 'Früh', '08:00', '14:00', true); + } + + public function slotsForMonth(string $teamCode, string $month): array { + return [new ShiftSlot(1, $teamCode, $month, $month . '-01', 'early', 'Früh', '08:00', '14:00', true)]; + } + + public function candidatesForSlotIds(array $slotIds): array { return []; } + public function dayNotesForMonth(string $teamCode, string $month): array { return []; } + + public function addCandidate(int $slotId, string $assistantUid, string $createdByUid): void { + $this->added[] = compact('slotId', 'assistantUid', 'createdByUid'); + } + + public function updateSlotDefinition(int $slotId, string $label, string $startsAt, string $endsAt, bool $enabled): void { + $this->updatedSlots[] = compact('slotId', 'label', 'startsAt', 'endsAt', 'enabled'); + } + + public function insertSlot( + string $teamCode, + string $month, + string $workDate, + string $segmentKey, + string $label, + string $startsAt, + string $endsAt, + bool $enabled + ): int { + return 100; + } +} + +final class MonthPlanStatusTeamAccessFake extends TeamAccessService { + public function __construct() {} +} + +final class MonthPlanStatusHintServiceFake extends PlanningHintService { + public function __construct() {} + public function forMonth(string $month, array $employeeUids): array { return []; } +} + +$assistants = [[ + 'uid' => 'assistant-a', + 'displayName' => 'Assistant A', + 'isEb' => false, + 'canReceiveShifts' => true, +]]; +$settings = ['shifts' => [[ + 'key' => 'early', + 'label' => 'Früh', + 'startsAt' => '08:00', + 'endsAt' => '14:00', + 'enabled' => true, +]]]; +$assistantTeam = new Team('A1', 'ad-ASN-A1', 'Team A1', $assistants, false, $settings); +$ebTeam = new Team('A1', 'ad-ASN-A1', 'Team A1', $assistants, true, $settings); +$store = new MonthPlanStatusStoreFake(); +$service = new ScheduleService($store, new ShiftConfigService(), new MonthPlanStatusTeamAccessFake(), new MonthPlanStatusHintServiceFake()); + +assertSameValue('draft', $service->monthPlan($ebTeam, '2026-08', 'test-eb')['status'] ?? null, 'A new month plan starts as draft.'); +assertDomainException( + static fn() => $service->transitionMonthStatus($assistantTeam, '2026-08', 'planned', 'assistant-a'), + 'Only the responsible EB may transition a month plan.' +); +assertDomainException( + static fn() => $service->transitionMonthStatus($ebTeam, '2026-08', 'approved', 'test-eb'), + 'A draft plan may not skip the planned state.' +); + +assertSameValue('planned', $service->transitionMonthStatus($ebTeam, '2026-08', 'planned', 'test-eb'), 'EB can mark a draft plan as planned.'); +assertSameValue('approved', $service->transitionMonthStatus($ebTeam, '2026-08', 'approved', 'test-eb'), 'EB can approve a planned plan.'); +assertDomainException( + static fn() => $service->addCandidate($ebTeam, '2026-08', 1, 'assistant-a', 'test-eb'), + 'Approved plans reject candidate mutations.' +); +assertDomainException( + static fn() => $service->saveDayNote($ebTeam, '2026-08-01', 'Gesperrt', 'test-eb'), + 'Approved plans reject note mutations.' +); + +$store->updatedSlots = []; +assertSameValue('approved', $service->monthPlan($ebTeam, '2026-08', 'test-eb')['status'] ?? null, 'Approved status remains visible after reload.'); +assertSameValue([], $store->updatedSlots, 'Loading an approved plan does not rewrite frozen slot definitions.'); + +assertSameValue('planned', $service->transitionMonthStatus($ebTeam, '2026-08', 'planned', 'test-eb'), 'EB has an explicit unlock path back to planned.'); +$service->addCandidate($ebTeam, '2026-08', 1, 'assistant-a', 'test-eb'); +assertSameValue(1, count($store->added), 'Unlocked plans accept candidate mutations again.'); +assertSameValue('draft', $service->transitionMonthStatus($ebTeam, '2026-08', 'draft', 'test-eb'), 'EB can explicitly reset a planned plan to draft.'); + +echo "AdPlaner month plan status tests passed\n"; diff --git a/tests/Service/PlanningHintServiceTest.php b/tests/Service/PlanningHintServiceTest.php new file mode 100644 index 0000000..722212f --- /dev/null +++ b/tests/Service/PlanningHintServiceTest.php @@ -0,0 +1,68 @@ +provideData) { + return $event; + } + $utc = new \DateTimeZone('UTC'); + if ($event instanceof AbsenceQueryEvent) { + $event->add(new AbsenceInterval('assistant-a', new \DateTimeImmutable('2026-08-02', $utc), new \DateTimeImmutable('2026-08-04', $utc), 'planned')); + $event->add(new AbsenceInterval('assistant-b', new \DateTimeImmutable('2026-08-05', $utc), new \DateTimeImmutable('2026-08-06', $utc), 'approved')); + } + if ($event instanceof ScheduleConflictQueryEvent && $event->employeeUid() === 'assistant-a') { + $event->add(new ScheduleConflict('appointment', new \DateTimeImmutable('2026-08-07 10:00:00', $utc), new \DateTimeImmutable('2026-08-07 11:00:00', $utc), 'Vertraulicher Titel')); + } + + return $event; + } + } + + $events = new PlanningHintEventDispatcherFake(); + $service = new PlanningHintService($events); + $hints = $service->forMonth('2026-08', ['assistant-a', 'assistant-b']); + + assertSameValue('U?', $hints['2026-08-02'][0]['marker'] ?? null, 'Planned vacation is exposed as U?.'); + assertSameValue(false, $hints['2026-08-02'][0]['blocks'] ?? null, 'Planning hints never block AdPlaner mutations.'); + assertSameValue('U', $hints['2026-08-05'][0]['marker'] ?? null, 'Approved vacation remains distinguishable.'); + assertSameValue('K', $hints['2026-08-07'][0]['marker'] ?? null, 'Calendar occupation is exposed as a compact marker.'); + assertSameValue('Termin', $hints['2026-08-07'][0]['label'] ?? null, 'Calendar titles are not leaked into the team plan.'); + assertSameValue('assistant-a', $hints['2026-08-07'][0]['employeeUid'] ?? null, 'Hints remain assigned to the visible team member.'); + + $events->provideData = false; + assertSameValue([], $service->forMonth('2026-08', ['assistant-a']), 'Missing providers are a valid empty standalone state.'); + + echo "AdPlaner planning hint service tests passed\n"; +} diff --git a/tests/Service/ScheduleServiceSmokeTest.php b/tests/Service/ScheduleServiceSmokeTest.php index 2ec9bb1..cbaa304 100644 --- a/tests/Service/ScheduleServiceSmokeTest.php +++ b/tests/Service/ScheduleServiceSmokeTest.php @@ -13,6 +13,7 @@ require __DIR__ . '/../../lib/Repository/ShiftPlanRepository.php'; require __DIR__ . '/../../lib/Service/ShiftConfigService.php'; require __DIR__ . '/../../lib/Service/TeamAccessService.php'; +require __DIR__ . '/../../lib/Service/PlanningHintService.php'; require __DIR__ . '/../../lib/Service/ScheduleService.php'; require __DIR__ . '/../../lib/Store/ShiftPlanStore.php'; @@ -22,6 +23,7 @@ use OCA\AdPlaner\Service\ScheduleService; use OCA\AdPlaner\Service\ShiftConfigService; use OCA\AdPlaner\Service\TeamAccessService; +use OCA\AdPlaner\Service\PlanningHintService; use OCA\AdPlaner\Store\ShiftPlanStore; use function OCA\AdPlaner\Tests\assertDomainException; use function OCA\AdPlaner\Tests\assertSameValue; @@ -35,6 +37,10 @@ class FakeShiftPlanStoreForSchedule extends ShiftPlanStore { public function __construct() { } + public function monthStatus(string $teamCode, string $month): string { + return 'draft'; + } + public function slotForMonth(int $slotId, string $teamCode, string $month): ?ShiftSlot { return new ShiftSlot($slotId, $teamCode, $month, $month . '-01', 'early', 'Früh', '08:00', '14:00', true); } @@ -117,6 +123,19 @@ public function __construct() { } } +class FakePlanningHintServiceForSchedule extends PlanningHintService { + public function __construct() {} + public function forMonth(string $month, array $employeeUids): array { + return [$month . '-01' => [[ + 'employeeUid' => 'assistant-a', + 'type' => 'absence', + 'marker' => 'U?', + 'label' => 'Urlaub', + 'blocks' => false, + ]]]; + } +} + $assistants = [ ['uid' => 'assistant-a', 'displayName' => 'Assistant A', 'isEb' => false, 'canReceiveShifts' => true], ['uid' => 'test-eb', 'displayName' => 'Test EB', 'isEb' => true, 'canReceiveShifts' => false], @@ -139,7 +158,7 @@ public function __construct() { assertSameValue('Team A1', $mappedTeam->toArray()['displayName'], 'Team::get should keep the API payload shape.'); $store = new FakeShiftPlanStoreForSchedule(); -$service = new ScheduleService($store, new ShiftConfigService(), new FakeTeamAccessServiceForSchedule()); +$service = new ScheduleService($store, new ShiftConfigService(), new FakeTeamAccessServiceForSchedule(), new FakePlanningHintServiceForSchedule()); $service->addCandidate($assistantTeam, '2026-07', 9, '', 'assistant-a'); assertSameValue('assistant-a', $store->added[0]['assistantUid'] ?? null, 'Assistant should be able to add themself.'); @@ -163,6 +182,7 @@ public function __construct() { $plan = $service->monthPlan($ebTeam, '2026-07', 'test-eb'); $slotCandidates = $plan['days'][0]['slots'][0]['candidates'] ?? []; assertSameValue(['assistant-a'], array_column($slotCandidates, 'uid'), 'Month plan should hide non-assignable EB candidates.'); +assertSameValue('Assistant A', $plan['days'][0]['hints'][0]['displayName'] ?? null, 'Planning hints use the visible team label without exposing foreign details.'); $configuredStore = new FakeShiftPlanStoreForSchedule(); $configuredStore->slots = [ @@ -176,7 +196,7 @@ public function __construct() { ['key' => 'night', 'label' => 'Nacht', 'startsAt' => '20:00', 'endsAt' => '08:00', 'enabled' => false], ], ]); -$configuredService = new ScheduleService($configuredStore, new ShiftConfigService(), new FakeTeamAccessServiceForSchedule()); +$configuredService = new ScheduleService($configuredStore, new ShiftConfigService(), new FakeTeamAccessServiceForSchedule(), new FakePlanningHintServiceForSchedule()); $configuredPlan = $configuredService->monthPlan($configuredTeam, '2026-07', 'test-eb'); $updatesById = []; foreach ($configuredStore->updatedSlots as $updatedSlot) { diff --git a/tests/Ui/LayoutSmokeTest.php b/tests/Ui/LayoutSmokeTest.php new file mode 100644 index 0000000..d350c50 --- /dev/null +++ b/tests/Ui/LayoutSmokeTest.php @@ -0,0 +1,22 @@ +&2 + exit 1 +fi + +month="$(date -u +%Y-%m)" +plan_endpoint="$ADP_BASE_URL/index.php/apps/adplaner/api/teams/$ADP_TEAM_CODE/months/$month" +curl --fail --silent --show-error --insecure --user "$ADP_USER:$ADP_PASSWORD" \ + --cookie "$cookies" --cookie-jar "$cookies" -H "requesttoken: $token" \ + "$plan_endpoint" --output "$plan" + +slot_id="$(php -r ' +$data = json_decode(file_get_contents($argv[1]), true, flags: JSON_THROW_ON_ERROR); +foreach (($data["days"] ?? []) as $day) { + foreach (($day["slots"] ?? []) as $slot) { + if (isset($slot["id"])) { + echo (int)$slot["id"]; + exit; + } + } +} +exit(1); +' "$plan")" + +candidate_endpoint="$plan_endpoint/slots/$slot_id/candidates" +status="$(curl --silent --show-error --insecure --user "$ADP_USER:$ADP_PASSWORD" \ + --cookie "$cookies" --cookie-jar "$cookies" -H "requesttoken: $token" -H 'Content-Type: application/json' \ + -X POST --data "{\"targetUid\":\"$ADP_FOREIGN_UID\"}" --write-out '%{http_code}' \ + --output "$error" "$candidate_endpoint")" +if [[ "$status" != '403' ]] || ! php -r ' +$data = json_decode(file_get_contents($argv[1]), true, flags: JSON_THROW_ON_ERROR); +exit(($data["ok"] ?? true) === false ? 0 : 1); +' "$error"; then + echo "Fremdänderung durch normales Teammitglied ergab HTTP $status statt eines verständlichen 403-Fehlers." >&2 + exit 1 +fi + +curl --fail --silent --show-error --insecure --user "$ADP_USER:$ADP_PASSWORD" \ + --cookie "$cookies" --cookie-jar "$cookies" -H "requesttoken: $token" \ + "$plan_endpoint" --output "$plan_after" +php -r ' +$data = json_decode(file_get_contents($argv[1]), true, flags: JSON_THROW_ON_ERROR); +$foreignUid = $argv[2]; +foreach (($data["days"] ?? []) as $day) { + foreach (($day["slots"] ?? []) as $slot) { + foreach (($slot["candidates"] ?? []) as $candidate) { + if (($candidate["assistantUid"] ?? "") === $foreignUid) { + throw new RuntimeException("Die abgewiesene Fremdänderung wurde dennoch gespeichert."); + } + } + } +} +' "$plan_after" "$ADP_FOREIGN_UID" + +echo "AdPlaner C3 API access smoke: OK ($ADP_USER)" diff --git a/tests/access-matrix-ddev-smoke.sh b/tests/access-matrix-ddev-smoke.sh new file mode 100755 index 0000000..50f08d6 --- /dev/null +++ b/tests/access-matrix-ddev-smoke.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_url="${ADP_BASE_URL:-https://nextcloud-dev.ddev.site}" +ddev_project="${ADP_DDEV_PROJECT:-$(cd "$(dirname "$0")/../../nextcloud-dev" && pwd)}" +suffix="$(date +%s)-$$" +password="$(php -r 'echo bin2hex(random_bytes(24));')" +team_code="Smoke$$" +team_group="ad-ASN-$team_code" +created_users=() + +occ() { + (cd "$ddev_project" && ddev exec -d /var/www/html/html php occ "$@") +} + +cleanup() { + local uid + for uid in "${created_users[@]}"; do + occ user:delete "$uid" >/dev/null 2>&1 || true + done + occ group:delete "$team_group" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +create_user() { + local uid="$1" + (cd "$ddev_project" && ddev exec -d /var/www/html/html env OC_PASS="$password" php occ user:add --password-from-env "$uid") >/dev/null + created_users+=("$uid") + occ group:adduser "$team_group" "$uid" >/dev/null +} + +occ group:add "$team_group" >/dev/null +actor="adp-smoke-${suffix}-actor" +foreign="adp-smoke-${suffix}-foreign" +create_user "$actor" +create_user "$foreign" + +ADP_BASE_URL="$base_url" ADP_USER="$actor" ADP_PASSWORD="$password" \ + ADP_TEAM_CODE="$team_code" ADP_FOREIGN_UID="$foreign" \ + "$(dirname "$0")/access-http-smoke.sh" + +echo 'AdPlaner DDEV access matrix smoke: OK' diff --git a/tests/integration/MonthPlanStatusSmoke.php b/tests/integration/MonthPlanStatusSmoke.php new file mode 100644 index 0000000..57953e0 --- /dev/null +++ b/tests/integration/MonthPlanStatusSmoke.php @@ -0,0 +1,44 @@ +get(IDBConnection::class); +$repository = \OC::$server->get(ShiftPlanRepository::class); + +$cleanup = static function () use ($db, $teamCode, $month): void { + $qb = $db->getQueryBuilder(); + $qb->delete('adp_month_plans') + ->where($qb->expr()->eq('team_code', $qb->createNamedParameter($teamCode))) + ->andWhere($qb->expr()->eq('plan_month', $qb->createNamedParameter($month))); + $qb->executeStatement(); +}; + +$cleanup(); +try { + if ($repository->monthStatus($teamCode, $month) !== null) { + throw new RuntimeException('Ein neuer Monatsplan besitzt unerwartet einen persistierten Status.'); + } + if (!$repository->transitionMonthStatus($teamCode, $month, 'draft', 'planned', 'rc6-test')) { + throw new RuntimeException('Der erste Statusübergang wurde nicht atomar persistiert.'); + } + if ($repository->transitionMonthStatus($teamCode, $month, 'draft', 'approved', 'rc6-test')) { + throw new RuntimeException('Ein veralteter Ausgangsstatus konnte den Plan überschreiben.'); + } + if ($repository->monthStatus($teamCode, $month) !== 'planned') { + throw new RuntimeException('Der persistierte Status ist nach einem Konflikt nicht erhalten geblieben.'); + } + if (!$repository->transitionMonthStatus($teamCode, $month, 'planned', 'approved', 'rc6-test')) { + throw new RuntimeException('Die Genehmigung wurde nicht persistiert.'); + } +} finally { + $cleanup(); +} + +echo "AdPlaner real month plan status persistence smoke passed\n"; diff --git a/tests/js/main-workflow-smoke.js b/tests/js/main-workflow-smoke.js index 5b49810..28e4d3c 100644 --- a/tests/js/main-workflow-smoke.js +++ b/tests/js/main-workflow-smoke.js @@ -8,6 +8,8 @@ const { const elements = createElementMap([ 'team-select', 'month-input', + 'month-prev', + 'month-next', 'adp-panel', ]); const tabs = new FakeElement('tabs'); @@ -111,6 +113,10 @@ class FakePlanRepository { repositoryCalls.push(['addSelf', teamCode, month, slotId]); } + async transitionStatus(teamCode, month, targetStatus) { + repositoryCalls.push(['transitionStatus', teamCode, month, targetStatus]); + } + } window.ADPlaner.repositories = { PlanRepository: FakePlanRepository }; @@ -138,6 +144,13 @@ async function flush() { assert(!elements.get('team-select').innerHTML.includes('Team ')); assert.strictEqual(elements.get('team-select').disabled, false); assert.strictEqual(elements.get('month-input').value, '2026-07'); + + await elements.get('month-prev').listeners.click(); + assert.strictEqual(elements.get('month-input').value, '2026-06'); + assert.deepStrictEqual(repositoryCalls.at(-1), ['monthPlan', 'TeamA', '2026-06']); + await elements.get('month-next').listeners.click(); + assert.strictEqual(elements.get('month-input').value, '2026-07'); + assert.deepStrictEqual(repositoryCalls.at(-1), ['monthPlan', 'TeamA', '2026-07']); assert.strictEqual(tabMonth.classList.has('is-active'), true); assert.strictEqual(tabMonth.getAttribute('aria-selected'), 'true'); assert.strictEqual(tabSettings.getAttribute('aria-selected'), 'false'); @@ -175,6 +188,17 @@ async function flush() { ]); assert.deepStrictEqual(errors, []); + await elements.get('adp-panel').listeners.click({ + target: new FakeButton({ + action: 'transition-status', + targetStatus: 'planned' + }) + }); + assert.deepStrictEqual(repositoryCalls.slice(-2), [ + ['transitionStatus', 'TeamB', '2026-07', 'planned'], + ['monthPlan', 'TeamB', '2026-07'] + ]); + console.log('AdPlaner main workflow smoke test passed.'); })().catch((error) => { console.error(error); diff --git a/tests/js/month-plan-smoke.js b/tests/js/month-plan-smoke.js index 26f103a..868347d 100644 --- a/tests/js/month-plan-smoke.js +++ b/tests/js/month-plan-smoke.js @@ -13,6 +13,7 @@ const { monthPlan } = window.ADPlaner; const basePlan = { month: '2026-07', + status: 'draft', segments: [ { key: 'early', label: 'Früh ', startsAt: '08:00', endsAt: '14:00' }, { key: 'late', label: 'Spät', startsAt: '14:00', endsAt: '20:00' } @@ -23,6 +24,10 @@ const basePlan = { dayOfMonth: 1, weekday: 3, note: '', + hints: [ + { employeeUid: 'assistant-a', displayName: 'Assistant ', marker: 'U?', label: 'Urlaub', blocks: false }, + { employeeUid: 'assistant-b', displayName: 'Assistant B', marker: 'K', label: 'Termin', blocks: false } + ], slots: [ { id: 10, segmentKey: 'early', candidates: [] }, { @@ -56,8 +61,12 @@ assert(assistantHtml.includes('Mi1')); assert(assistantHtml.includes('data-action="add-self" data-slot-id="10"')); assert(!assistantHtml.includes('data-action="add-self" data-slot-id="11"')); assert(!assistantHtml.includes('adp-assignment-control')); +assert(!assistantHtml.includes('data-action="transition-status"')); assert(assistantHtml.includes('<Hinweis>')); assert(!assistantHtml.includes('')); +assert(assistantHtml.includes('U? Assistant <A>')); +assert(assistantHtml.includes('K Assistant B')); +assert(!assistantHtml.includes('Assistant ')); const ebHtml = monthPlan.render({ ...basePlan, @@ -78,5 +87,24 @@ assert(ebHtml.includes('adp-assignment-control')); assert(ebHtml.includes('data-action="remove-candidate" data-slot-id="11" data-target-uid="assistant-a"')); assert(ebHtml.includes('')); assert(ebHtml.includes('value="assistant-b"')); +assert(ebHtml.includes('Entwurf')); +assert(ebHtml.includes('data-action="transition-status" data-target-status="planned"')); + +const approvedHtml = monthPlan.render({ + ...basePlan, + status: 'approved', + team: { + code: 'A1', + displayName: 'Team A1', + canCoordinate: true, + settings: {}, + assistants: [{ uid: 'assistant-a', displayName: 'Assistant A', canReceiveShifts: true }] + } +}, { uid: 'eb' }); +assert(approvedHtml.includes('Genehmigt')); +assert(approvedHtml.includes('data-action="transition-status" data-target-status="planned"')); +assert(!approvedHtml.includes('adp-assignment-control')); +assert(!approvedHtml.includes('data-action="remove-candidate"')); +assert(!approvedHtml.includes(' Date: Sat, 8 Aug 2026 19:58:20 +0200 Subject: [PATCH 07/10] Document AdPlaner automated acceptance proof --- docs/manual-acceptance.md | 22 +++++++++++++++++++++- tests/integration-ddev-smoke.sh | 7 +++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100755 tests/integration-ddev-smoke.sh diff --git a/docs/manual-acceptance.md b/docs/manual-acceptance.md index fbbcfdb..286feae 100644 --- a/docs/manual-acceptance.md +++ b/docs/manual-acceptance.md @@ -125,4 +125,24 @@ Begründung verpflichtend. 8. **C3 – Serverseitige Rechteprüfung nachholen** - Fremdänderung durch ein normales Teammitglied über einen direkten API-Aufruf versuchen. - - Verifizieren, dass der Server die Änderung abweist und der bestehende Eintrag unverändert bleibt. \ No newline at end of file + - Verifizieren, dass der Server die Änderung abweist und der bestehende Eintrag unverändert bleibt. + +## Automatisierter Nachweis nach der historischen Abnahme + +Die vorstehenden Ergebnisse und die Gesamtentscheidung bleiben als historischer +manueller Stand vom 02.08.2026 unverändert. Für `0.4.0-rc.1` sind B7 und D3 +technisch umgesetzt und durch Service-, API-, JavaScript- und Layouttests +abgesichert: Die zuständige EB kann ausschließlich die erlaubten Übergänge +`draft` → `planned` → `approved` sowie die ausdrücklichen Rückwege ausführen, +ein genehmigter Monatsplan ist serverseitig und in der Oberfläche gesperrt, +und optionale Urlaubs-/Kalenderhinweise bleiben read-only. Der reale +DDEV-Integrationslauf belegt Statuspersistenz und unzulässige Übergänge gegen +die Nextcloud-34-Datenbank. Die selbstbereinigende DDEV-Rechtematrix deckt den +direkten serverseitigen Negativfall aus C3 ab. + +Auch die technischen Korrekturen zu A4 und A6 sind automatisiert belegt: +direkte Vor-/Zurücknavigation und ein begrenzter, horizontal wie vertikal +scrollbarer Plan-Viewport mit sichtbarer rechter Aktionsspalte. Die manuelle +visuelle und fachliche Wiederholungsabnahme sowie die konkrete fachliche +Wirkung der optionalen Hinweise auf Wünsche und Zuweisungen bleiben vor einer +Produktfreigabe erforderlich. diff --git a/tests/integration-ddev-smoke.sh b/tests/integration-ddev-smoke.sh new file mode 100755 index 0000000..2c0b5b6 --- /dev/null +++ b/tests/integration-ddev-smoke.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ddev_project="${ADP_DDEV_PROJECT:-$(cd "$(dirname "$0")/../../nextcloud-dev" && pwd)}" + +(cd "$ddev_project" && ddev exec -d /var/www/html/html php -r \ + "define('OC_CONSOLE', true); require '/var/www/html/html/custom_apps/adplaner/tests/integration/MonthPlanStatusSmoke.php';") From 6800a80d2a4ef6aa4e17ae18311ee99076600785 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 9 Aug 2026 12:13:20 +0200 Subject: [PATCH 08/10] Align app metadata with Nextcloud schema --- appinfo/info.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 7976abe..ecec396 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -8,11 +8,11 @@ 0.4.0-rc.1 agpl Simon + AdPlaner + organization https://github.com/Filzmann/ad-suite https://github.com/Filzmann/nextcloud-adplaner/issues https://github.com/Filzmann/nextcloud-adplaner - AdPlaner - organization From 5220d15708c78201c9818b9ebebc979b5794755d Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 9 Aug 2026 13:42:19 +0200 Subject: [PATCH 09/10] chore: sync app work instructions --- .agents/skills/work-in-nextcloud-app/SKILL.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.agents/skills/work-in-nextcloud-app/SKILL.md b/.agents/skills/work-in-nextcloud-app/SKILL.md index 35e0ff6..4650c39 100644 --- a/.agents/skills/work-in-nextcloud-app/SKILL.md +++ b/.agents/skills/work-in-nextcloud-app/SKILL.md @@ -57,7 +57,9 @@ Stop immediately if production systems, Git history rewriting, new production de - Develop executable UI logic test-first; additionally cover layout, accessibility, and Nextcloud integration with suitable smoke or browser checks. - Treat a time-boxed exploratory spike or hard-to-isolate Nextcloud integration as an explicitly justified deviation. Discard spike code or characterize it before adoption; choose the truthful broader integration level when isolation would hide the real contract. - Use the local fast entries named by `AGENTS.md`, normally `php tests/run.php` and `node tests/run-js.mjs`; dependency-light PHP smokes run in isolated processes. Run LocalBase and every affected consumer contract/smoke suite after a LocalBase contract change. -- In local pre-production, app tests may use shared LocalBase test helpers through relative repository paths. Add heavier packaging/autoload structure or a larger test framework only when path handling, runners, assertions, mocks, or fixtures are materially duplicated or impair readability. +- PHP classes must not remain coupled through distributed relative `require` or `require_once` chains. Production classes under `lib/` use Nextcloud's PSR-4 app autoloader; a bundled category-A dependency uses exactly one reproducibly generated, app-local, namespace-isolated Composer autoloader. Never use a shared workspace autoloader, shared cross-app `vendor/`, or neighboring production repository path as a delivery mechanism. Category-B services remain separate runtime apps and are consumed only through their public activation- and version-aware contracts. +- Every app uses one central app-local test bootstrap/autoloader for dependency-light PHP tests. It may resolve a test-only LocalBase helper through exactly one central transition point until a versioned development dependency exists, but individual tests must not retain direct relative LocalBase class paths after migration. Nextcloud integration tests may load the documented Nextcloud test bootstrap; template partials, explicit process/test entrypoints, and the one-time bootstrap of a bundled Composer autoloader remain justified includes rather than class-load chains. +- When a writing task first touches executable PHP or PHP tests in an app that has not yet migrated, include that app's complete autoload migration as a separate preparatory step in the same app scope. An app is complete only when distributed manual class requires and production fallback requires are gone, allowed includes are limited and reviewable, relevant app and provider/consumer PHP tests are green, and release checks contain neither development dependencies nor foreign repository paths. Documentation-only, formatting-only, and JavaScript-only work does not trigger an artificial PHP migration. - Known overall and app coverage must not decline unnoticed. Aim for at least 85 percent line coverage for new or materially changed executable code, report PHP and JavaScript separately, and fully cover security invariants regardless of percentages. Coverage is a warning and delivery indicator, not a substitute for meaningful assertions. - Do not prepare a commit or release with red relevant fast tests, contract tests, security checks, coverage gates, or delivery gates. @@ -82,6 +84,7 @@ Stop immediately if production systems, Git history rewriting, new production de ## Git and completion report +- When Simon asks for the next open steps, priorities, remaining work, or a similar outlook, include every applicable pending migration and unresolved decision from accepted ADRs and documented rollout plans. Report its current status, trigger, and required approval gate, and distinguish work executable now from work triggered by a later app change and work that is currently undecidable. Mentioning an item does not expand the current write scope or authorize a gated change. - Do not commit, push, release, deploy, or use `git add .` without Simon's explicit authorization. Stage individual files only when staging was requested. - Before a commit, show `git status --short`, `git diff --stat`, and `git diff --name-only`. Never use `git reset --hard`, `git clean`, force-push, history rewrite, or versioned backup copies. - Run relevant local tests, `git diff --check`, and the repository's own structure/fast check. For an explicitly authorized cross-app contract change, validate every provider and consumer repository from its own root and use the Parent workspace check only as an additional coordinator. From 137ffbd25de4f5ae10541d7285ecc0d058271a93 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 9 Aug 2026 18:07:29 +0200 Subject: [PATCH 10/10] fix: stabilize planning workflows --- CHANGELOG.md | 10 + README.md | 2 + ROADMAP.md | 29 +-- appinfo/info.xml | 2 +- appinfo/routes.php | 4 +- css/style.css | 30 ++- docs/manual-acceptance.md | 33 +++ js/admin.js | 4 +- js/components/assignment-control.js | 45 ++-- js/components/candidate-chip.js | 5 +- js/components/day-note-control.js | 6 +- js/components/month-plan.js | 12 +- js/components/plan-chrome.js | 30 ++- js/components/settings-panel.js | 2 +- js/components/shift-settings-list.js | 2 +- js/models/shift-candidate.js | 4 - js/modules/plan-app.js | 83 ++++++-- lib/Controller/ApiController.php | 4 +- lib/Controller/DemoAdminController.php | 5 +- .../Version000004Date202608090001.php | 33 +++ lib/Model/Assistant.php | 3 +- lib/Model/ShiftCandidate.php | 1 - lib/Model/Team.php | 8 +- lib/Repository/ShiftPlanRepository.php | 99 ++++++++- lib/Repository/TeamSettingsRepository.php | 11 +- lib/Service/PlanningHintService.php | 71 ++++--- lib/Service/ScheduleService.php | 125 +++++++++-- lib/Service/ShiftConfigService.php | 26 ++- lib/Service/TeamAccessService.php | 13 +- lib/Store/ShiftPlanStore.php | 16 ++ lib/Store/TeamSettingsStore.php | 15 +- templates/index.php | 2 +- tests/AccessSmokeContractTest.php | 24 +++ tests/BrowserDdevSmokeContractTest.php | 31 +++ .../ControllerAttributeSmokeTest.php | 3 +- tests/Controller/DemoAdminControllerTest.php | 138 ++++++++++++ tests/IntegrationCapabilityListenerTest.php | 4 +- tests/MigrationContractTest.php | 17 ++ tests/MigrationExecutionTest.php | 130 ++++++++++++ tests/PrivacyDdevSmokeContractTest.php | 25 +++ tests/Repository/ShiftPlanRepositoryTest.php | 149 +++++++++++++ .../Repository/TeamSettingsRepositoryTest.php | 167 +++++++++++++++ tests/RouteContractTest.php | 19 ++ tests/Service/MonthPlanStatusServiceTest.php | 104 +++++++-- tests/Service/PlanningHintServiceTest.php | 53 ++++- tests/Service/ScheduleServiceSmokeTest.php | 163 ++++++++++++-- tests/Service/ShiftConfigServiceSmokeTest.php | 89 +++++++- tests/Service/TeamAccessServiceSmokeTest.php | 52 ++++- tests/Service/TeamSettingsStoreTest.php | 64 ++++++ tests/Service/helpers.php | 2 - tests/StandaloneNavigationListenerTest.php | 2 +- tests/Ui/LayoutSmokeTest.php | 7 +- tests/access-http-smoke.sh | 27 ++- tests/bootstrap.php | 29 +++ tests/browser-ddev-smoke.sh | 127 +++++++++++ tests/integration/MonthPlanStatusSmoke.php | 87 +++++++- tests/integration/PrivacyRuntimeProbe.php | 149 +++++++++++++ tests/js/admin-smoke.js | 66 ++++++ tests/js/assignment-control-smoke.js | 72 +++++-- tests/js/browser-ddev-smoke.mjs | 199 ++++++++++++++++++ tests/js/main-workflow-smoke.js | 122 +++++++++++ tests/js/model-smoke.js | 8 +- tests/js/month-plan-smoke.js | 24 ++- tests/js/plan-app-settings-smoke.js | 124 +++++++++++ tests/js/settings-panel-smoke.js | 3 + tests/privacy-ddev-smoke.sh | 137 ++++++++++++ tests/run-js.mjs | 2 + tests/run.php | 2 +- 68 files changed, 2915 insertions(+), 241 deletions(-) create mode 100644 lib/Migration/Version000004Date202608090001.php create mode 100644 tests/AccessSmokeContractTest.php create mode 100644 tests/BrowserDdevSmokeContractTest.php create mode 100644 tests/Controller/DemoAdminControllerTest.php create mode 100644 tests/MigrationExecutionTest.php create mode 100644 tests/PrivacyDdevSmokeContractTest.php create mode 100644 tests/Repository/ShiftPlanRepositoryTest.php create mode 100644 tests/Repository/TeamSettingsRepositoryTest.php create mode 100644 tests/RouteContractTest.php create mode 100644 tests/Service/TeamSettingsStoreTest.php create mode 100644 tests/bootstrap.php create mode 100755 tests/browser-ddev-smoke.sh create mode 100644 tests/integration/PrivacyRuntimeProbe.php create mode 100644 tests/js/admin-smoke.js create mode 100644 tests/js/browser-ddev-smoke.mjs create mode 100644 tests/js/plan-app-settings-smoke.js create mode 100755 tests/privacy-ddev-smoke.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2e159..a5cdcf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.4.0-rc.2 + +- Monatsplanänderungen und Statuswechsel gegen konkurrierende Freigaben serialisiert; genehmigte Schichtdefinitionen bleiben als unveränderlicher Planstand erhalten. +- Strikte Monats-, Datums-, UTF-8-, Längen-, Boolean- und Schichtlistenvalidierung ergänzt. +- Teameinstellungen, Tagesbemerkungen und Zuweisungen beim konkurrierenden erstmaligen Speichern gegen Unique-Constraint-Konflikte abgesichert. +- Geleerte Tagesbemerkungen samt Bearbeitungsmetadaten entfernt; öffentliche Team- und Hinweisantworten auf aktuell schichtfähige Personen und benötigte Anzeigenamen reduziert. +- Technische Monats-Locks von personenbezogenen Änderungsmetadaten getrennt und interne Demo-Fehler ohne Detailleck als Serverfehler beantwortet. +- Fehler optionaler Hinweisprovider isoliert und öffentliche Planpayloads um interne Erstellerkennungen sowie teamfremde Hinweise bereinigt. +- Lade-, Speicher- und Zuteilungsoberfläche gegen veraltete Antworten, Doppelstarts, unklare Nachladefehler sowie Tastatur-, Tabellen- und Fokusprobleme stabilisiert. + ## 0.4.0-rc.1 - Kontrollierte Monatsplanstatus `draft`, `planned` und `approved` ergänzt; nur die zuständige Einsatzbegleitung darf wechseln und genehmigte Pläne sind gegen Änderungen gesperrt. diff --git a/README.md b/README.md index 2de183f..db4c08b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Assistenzteams werden aus den zentral konfigurierten Nextcloud-Gruppen abgeleite Die zuständige Einsatzbegleitung führt Monatspläne kontrolliert von `draft` über `planned` nach `approved`. Genehmigte Pläne sind bis zu einer ausdrücklichen Rücknahme gegen Wünsche, Zuweisungen und Bemerkungsänderungen gesperrt. Optionale Urlaubs- und Kalenderprovider liefern ausschließlich datensparsame, schreibgeschützte Planungshinweise. +Genehmigte Pläne frieren die damaligen Schichtdefinitionen ein, speichern aber keine zusätzlichen historischen Personenstammdaten. Zuweisungen werden weiterhin nur für aktuell schichtfähige Mitglieder des jeweiligen Assistenzteams angezeigt. + ## Roadmap Geplante Erweiterungen und offene Produktentscheidungen stehen in der [Roadmap](ROADMAP.md). diff --git a/ROADMAP.md b/ROADMAP.md index d634cec..4e50044 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,24 +2,6 @@ Diese Datei bündelt geplante Erweiterungen und offene Produktentscheidungen. Verbindliche Fach-, Sicherheits- und Architekturregeln stehen in `AGENTS.md`. -## Freigegebene Umsetzungsaufgaben - -### ADP-L10N – Assistenzplanung vollständig lokalisieren - -Status: als l10n-Pilot geeignet - -- Manuelle Monats-/Wochentagsnamen und sichtbare UI-, Status-, Validierungs- - und Fehlermeldungen auf aktive Nextcloud-Locale und Nextcloud-l10n - umstellen. -- ISO-Daten, Monatsnummern, Schichtzeiten, Statuswerte, Teamcodes und - API-Schlüssel unverändert lassen; Abkürzungen nicht durch Abschneiden - bilden. -- Deutsche Ausgabe, eine weitere Locale, Fallback, Monats-/Jahresgrenzen, - Pluralformen, Platzhalter und Escaping in PHP und JavaScript testen. -- Erst nach vollständiger Pilotmigration den app-eigenen Rohtext-Check - verbindlich schalten und seinen Vertrag für die weiteren Apps - dokumentieren. - ## Aktueller Fokus - Die manuellen Prüfungen werden im ausfüllbaren @@ -29,6 +11,17 @@ Status: als l10n-Pilot geeignet ## Geplante Erweiterungen +- **ADP-L10N – vollständige Lokalisierung (später, nicht freigegeben):** + AdPlaner wird im Rahmen des suiteweiten L10N-Rollouts auf die aktive + Nextcloud-Locale und Nextcloud-l10n umgestellt. Manuelle Monats- und + Wochentagsnamen sowie sichtbare UI-, Status-, Validierungs- und + Fehlermeldungen werden dabei vollständig migriert. ISO-Daten, + Monatsnummern, Schichtzeiten, Statuswerte, Teamcodes und API-Schlüssel + bleiben unverändert; Abkürzungen werden nicht durch Abschneiden gebildet. + Erforderlich sind Tests für deutsche Ausgabe, mindestens eine weitere + Locale, Fallback, Monats-/Jahresgrenzen, Pluralformen, Platzhalter und + Escaping in PHP und JavaScript. Pilot-App, Reihenfolge und Rohtext-Gate + werden vor Umsetzung suiteweit separat freigegeben. - Persönliche Monatsansicht „Alle meine Einsätze“ mit PDF-Export und optionaler Verbindung zu gängigen Kalendern. - Benachrichtigungen für relevante Planungs- und Statusänderungen. - Teambezogene Konfigurierbarkeit nur dort erweitern, wo konkrete Teams unterschiedliche Regeln benötigen. diff --git a/appinfo/info.xml b/appinfo/info.xml index ecec396..3216955 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ Assistenz Dienstplanung Wunschdienstplanung für Assistenzteams. Verwaltet monatliche Wunschdienstpläne für Assistenzteams auf Basis dynamischer Nextcloud-Gruppen. - 0.4.0-rc.1 + 0.4.0-rc.2 agpl Simon AdPlaner diff --git a/appinfo/routes.php b/appinfo/routes.php index fa938bc..4efa5be 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -9,8 +9,8 @@ ['name' => 'api#transitionMonthStatus', 'url' => '/api/teams/{teamCode}/months/{month}/status', 'verb' => 'POST'], ['name' => 'api#saveTeamSettings', 'url' => '/api/teams/{teamCode}/settings', 'verb' => 'POST'], ['name' => 'api#saveDayNote', 'url' => '/api/teams/{teamCode}/months/{month}/days/{workDate}/note', 'verb' => 'POST'], - ['name' => 'api#addShiftCandidate', 'url' => '/api/teams/{teamCode}/months/{month}/slots/{slotId}/candidates', 'verb' => 'POST'], - ['name' => 'api#removeShiftCandidate', 'url' => '/api/teams/{teamCode}/months/{month}/slots/{slotId}/candidates/remove', 'verb' => 'POST'], + ['name' => 'api#addShiftCandidate', 'url' => '/api/teams/{teamCode}/months/{month}/slots/{slotId}/candidates', 'verb' => 'POST', 'requirements' => ['slotId' => '\\d+']], + ['name' => 'api#removeShiftCandidate', 'url' => '/api/teams/{teamCode}/months/{month}/slots/{slotId}/candidates/remove', 'verb' => 'POST', 'requirements' => ['slotId' => '\\d+']], ['name' => 'demo_admin#install', 'url' => '/api/admin/demo-pack/install', 'verb' => 'POST'], ], ]; diff --git a/css/style.css b/css/style.css index cef6af0..8175b4b 100644 --- a/css/style.css +++ b/css/style.css @@ -244,15 +244,37 @@ margin: 0; } -.adp-assignment-control, -.adp-assignment-picker { +.adp-assignment-control { + position: relative; display: inline-flex; align-items: center; gap: 4px; } -.adp-assignment-picker select { - width: 150px; +.adp-assignment-picker { + position: absolute; + left: 0; + top: calc(100% + 4px); + z-index: 5; + display: grid; + gap: 4px; + min-width: 180px; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 6px; + background: var(--color-main-background); + box-shadow: 0 4px 16px rgba(0, 0, 0, .18); +} + +.adp-assignment-picker[hidden] { + display: none; +} + +.adp-assignment-picker .adp-small { + width: 100%; + text-align: left; } .adp-note-cell textarea { diff --git a/docs/manual-acceptance.md b/docs/manual-acceptance.md index 286feae..5db11ee 100644 --- a/docs/manual-acceptance.md +++ b/docs/manual-acceptance.md @@ -146,3 +146,36 @@ scrollbarer Plan-Viewport mit sichtbarer rechter Aktionsspalte. Die manuelle visuelle und fachliche Wiederholungsabnahme sowie die konkrete fachliche Wirkung der optionalen Hinweise auf Wünsche und Zuweisungen bleiben vor einer Produktfreigabe erforderlich. + +Für `0.4.0-rc.2` belegen zusätzliche Repository-, Service-, Routen- und +JavaScript-Tests die Serialisierung konkurrierender Planänderungen, strikte +Eingabegrenzen, idempotente Erstschreibvorgänge, das datensparsame Entfernen +geleerter Tagesbemerkungen, ausschließlich aktuell schichtfähige Personen in +öffentlichen Teamantworten sowie UID-freie read-only Planungshinweise. Der +technische Monats-Lock verändert keine personenbezogenen fachlichen +Änderungsmetadaten. Unbekannte Planstatus bleiben auch in der Oberfläche +gesperrt; Tabellenüberschriften und Bemerkungsfelder sind programmatisch +beschriftet. Diese automatisierten Nachweise ersetzen die ausstehende visuelle +und fachliche Wiederholungsabnahme nicht. + +Ein zusätzlicher selbstbereinigender Nextcloud-34-DDEV-Smoke bestätigt diese +Datenschutzverträge über den echten Gruppen-, HTTP-/CSRF- und Datenbankpfad: +Deaktivierte Konten und EB-Konten werden nicht als schichtfähig ausgeliefert, +das Leeren einer Tagesbemerkung entfernt deren Datenbankzeile, und technische +Monats-Locks überschreiben keine fachlichen Änderungsmetadaten. Die getrennte +Cleanup-Nachprüfung fand anschließend weder synthetische AdPlaner-Daten noch +die temporären Konten oder die temporäre Gruppe. + +Eine selbstbereinigende Headless-Chrome-Wiederholungsabnahme belegt zusätzlich +die gerenderte und interaktive Oberfläche in Nextcloud 34 mit einem +synthetischen EB- und einem normalen Teamkonto. Geprüft wurden direkte +Monatsnavigation, Tastaturwechsel der Tabs, Fremdzuweisung durch EB, eigener +Wunsch, persistierte Tagesbemerkung und Schichtkonfiguration, die Übergänge +`draft` → `planned` → `approved` einschließlich Sperre und ausdrücklichem +Entsperren, schreibgeschützte Einstellungen für normale Mitglieder sowie das +reale vertikale und horizontale Scrollverhalten bei schmalem Viewport. Ein +temporärer Sichtnachweis wurde nur im Testverzeichnis unter `/tmp` erzeugt und +mit dem Browserprofil gelöscht. Eine unabhängige Nachprüfung fand danach keine +synthetischen Konten oder über deren Bearbeiterkennung auffindbaren Plan- und +Bemerkungsdaten. Nicht Bestandteil dieses Laufs war das Umschalten von +OrgSuite oder optionalen Provider-Apps. diff --git a/js/admin.js b/js/admin.js index bbaf31a..a6f299a 100644 --- a/js/admin.js +++ b/js/admin.js @@ -7,13 +7,13 @@ const client = new window.LocalBase.api.ApiClient({ appId: 'adplaner' }); confirmation.addEventListener('change', () => { button.disabled = !confirmation.checked; }); button.addEventListener('click', async () => { - if (!confirmation.checked) return; + if (!confirmation.checked || button.disabled) return; button.disabled = true; notice.hidden = false; notice.className = 'adp-admin-notice'; notice.textContent = 'Demo-Pack wird geprüft und installiert …'; try { - const response = await client.request('/api/admin/demo-pack/install', { method: 'POST', body: '{}' }); + const response = await client.request('/api/admin/demo-pack/install', { method: 'POST', body: '{"confirmed":true}' }); notice.classList.add('is-success'); notice.textContent = `${response.result.teams.join(', ')} wurden als Demoteams angelegt.`; confirmation.checked = false; diff --git a/js/components/assignment-control.js b/js/components/assignment-control.js index c17b9f9..4829b9c 100644 --- a/js/components/assignment-control.js +++ b/js/components/assignment-control.js @@ -6,22 +6,20 @@ const assistants = (team.assistants || []).filter(assistant => { return assistant.canReceiveShifts !== false && !assigned.has(assistant.uid); }); + const pickerId = `adp-assignment-picker-${esc(slot.id)}`; return ` - - `; } - function option(assistant) { - return ``; + function option(assistant, slotId) { + return ``; } function open(button) { @@ -30,14 +28,33 @@ return; } - document.querySelectorAll('[data-assignment-picker]').forEach(picker => { - picker.hidden = picker.dataset.assignmentPicker !== slotId; + const picker = document.querySelector(`[data-assignment-picker="${CSS.escape(slotId)}"]`); + const shouldOpen = !!picker && picker.hidden; + document.querySelectorAll('[data-assignment-picker]').forEach(candidate => { + candidate.hidden = true; + }); + document.querySelectorAll('[data-assignment-trigger]').forEach(trigger => { + trigger.setAttribute('aria-expanded', 'false'); }); + if (!shouldOpen) { + return; + } - const picker = document.querySelector(`[data-assignment-picker="${CSS.escape(slotId)}"]`); - const select = picker ? picker.querySelector('select') : null; - if (select) { - select.focus(); + picker.hidden = false; + button.setAttribute('aria-expanded', 'true'); + if (!picker.dataset.escapeBound) { + picker.addEventListener('keydown', event => { + if (event.key !== 'Escape') return; + event.preventDefault(); + picker.hidden = true; + button.setAttribute('aria-expanded', 'false'); + button.focus(); + }); + picker.dataset.escapeBound = 'true'; + } + const firstOption = picker ? picker.querySelector('button[data-action="add-selected"]') : null; + if (firstOption) { + firstOption.focus(); } } diff --git a/js/components/candidate-chip.js b/js/components/candidate-chip.js index b1e9852..c780b8d 100644 --- a/js/components/candidate-chip.js +++ b/js/components/candidate-chip.js @@ -3,11 +3,12 @@ function render(candidate, canCoordinate, slotId, mutable = true) { const removable = mutable && (canCoordinate || candidate.isSelf); + const displayName = candidate.displayName || candidate.uid; return ` - ${esc(candidate.displayName || candidate.uid)} - ${removable ? `` : ''} + ${esc(displayName)} + ${removable ? `` : ''} `; } diff --git a/js/components/day-note-control.js b/js/components/day-note-control.js index e969f27..192a5d8 100644 --- a/js/components/day-note-control.js +++ b/js/components/day-note-control.js @@ -1,5 +1,5 @@ (function() { - const { esc } = window.ADPlaner.ui; + const { esc, dateShort } = window.ADPlaner.ui; function render(day, canCoordinate) { if (!canCoordinate) { @@ -7,8 +7,8 @@ } return ` - - + + `; } diff --git a/js/components/month-plan.js b/js/components/month-plan.js index 503928b..7787032 100644 --- a/js/components/month-plan.js +++ b/js/components/month-plan.js @@ -12,8 +12,8 @@ const team = plan.team; const segments = plan.segments || []; const canCoordinate = !!team.canCoordinate; - const status = plan.status || 'draft'; - const mutable = status !== 'approved'; + const status = typeof plan.status === 'string' ? plan.status : ''; + const mutable = status === 'draft' || status === 'planned'; return `
@@ -28,9 +28,9 @@ - - ${segments.map(segment => ``).join('')} - + + ${segments.map(segment => ``).join('')} + @@ -63,7 +63,7 @@ return ` - + ${segments.map(segment => slotCell(slotsByKey[segment.key], team, currentUser, canCoordinate, mutable)).join('')} diff --git a/js/components/plan-chrome.js b/js/components/plan-chrome.js index 2dc4786..b3ef712 100644 --- a/js/components/plan-chrome.js +++ b/js/components/plan-chrome.js @@ -14,12 +14,20 @@ this.monthPrevious = this.byId('month-prev'); this.monthNext = this.byId('month-next'); this.panel = this.byId('adp-panel'); + this.currentMonth = ''; this.tabs = document.querySelector('.adp-tabs'); this.tabButtons = Array.from(document.querySelectorAll('.adp-tab')); this.teamSelect.addEventListener('change', event => this.onTeamChange(event.target.value)); - this.monthInput.addEventListener('change', event => this.onMonthChange(event.target.value)); - this.monthPrevious.addEventListener('click', () => this.onMonthChange(this.offsetMonth(this.monthInput.value, -1))); - this.monthNext.addEventListener('click', () => this.onMonthChange(this.offsetMonth(this.monthInput.value, 1))); + this.monthInput.addEventListener('change', event => { + const value = event.target.value; + if (!this.isValidMonth(value)) { + event.target.value = this.currentMonth; + return; + } + return this.onMonthChange(value); + }); + this.monthPrevious.addEventListener('click', () => this.changeMonthBy(-1)); + this.monthNext.addEventListener('click', () => this.changeMonthBy(1)); this.tabs.addEventListener('click', event => { const button = event.target instanceof Element ? event.target.closest('button[data-view]') : null; if (button) return this.onViewChange(button.dataset.view || 'month'); @@ -48,13 +56,29 @@ return `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, '0')}`; } + isValidMonth(month) { + const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(month || ''); + if (!match) return false; + const year = Number(match[1]); + return year >= 2000 && year <= 2100; + } + + changeMonthBy(delta) { + const target = this.offsetMonth(this.monthInput.value, delta); + if (!this.isValidMonth(target)) return; + return this.onMonthChange(target); + } + render(state) { this.teamSelect.innerHTML = state.teams.map(team => { const selected = team.code === state.selectedTeamCode ? ' selected' : ''; return ``; }).join(''); this.teamSelect.disabled = state.teams.length === 0; + this.currentMonth = state.month; this.monthInput.value = state.month; + this.monthPrevious.disabled = state.month === '2000-01'; + this.monthNext.disabled = state.month === '2100-12'; let activeTabId = ''; this.tabButtons.forEach(button => { const active = button.dataset.view === state.activeView; diff --git a/js/components/settings-panel.js b/js/components/settings-panel.js index 5d5d51c..7147fb1 100644 --- a/js/components/settings-panel.js +++ b/js/components/settings-panel.js @@ -32,7 +32,7 @@

${esc(team.displayName || team.code)}

- +
Schichten diff --git a/js/components/shift-settings-list.js b/js/components/shift-settings-list.js index 9c4fa5a..5f81851 100644 --- a/js/components/shift-settings-list.js +++ b/js/components/shift-settings-list.js @@ -34,7 +34,7 @@ - + `; } diff --git a/js/models/shift-candidate.js b/js/models/shift-candidate.js index e5cf894..96f3df5 100644 --- a/js/models/shift-candidate.js +++ b/js/models/shift-candidate.js @@ -11,8 +11,6 @@ this.assistantUid = data.assistantUid || data.assistant_uid || data.uid || ''; this.uid = this.assistantUid; this.displayName = data.displayName || data.display_name || this.uid; - this.createdByUid = data.createdByUid || data.created_by_uid || ''; - this.createdAt = data.createdAt || data.created_at || ''; this.isSelf = !!(data.isSelf ?? data.is_self ?? false); } @@ -23,8 +21,6 @@ assistantUid: this.assistantUid, uid: this.uid, displayName: this.displayName, - createdByUid: this.createdByUid, - createdAt: this.createdAt, isSelf: this.isSelf }; } diff --git a/js/modules/plan-app.js b/js/modules/plan-app.js index 415a56a..64b1a79 100644 --- a/js/modules/plan-app.js +++ b/js/modules/plan-app.js @@ -10,6 +10,8 @@ this.repository = options.repository; this.showNotice = options.showNotice; this.showError = options.showError; + this.loadVersion = 0; + this.settingsSaving = false; this.state = { currentUser: null, teams: [], selectedTeamCode: '', month: '', activeView: 'month', monthPlan: null, organization: {}, loading: false }; this.chrome = new options.PlanChrome({ byId: options.byId, esc: options.esc, @@ -55,53 +57,104 @@ } async reloadActive() { + const loadVersion = ++this.loadVersion; this.state.loading = true; this.render(); this.showNotice(''); try { - if (this.state.activeView === 'settings') this.applyState(await this.repository.state()); - else await this.loadMonth(); - } catch (error) { this.showError(error, 'Ansicht konnte nicht geladen werden.'); } - finally { this.state.loading = false; this.render(); } + if (this.state.activeView === 'settings') { + const data = await this.repository.state(); + if (loadVersion === this.loadVersion) this.applyState(data); + } else { + await this.loadMonth(loadVersion); + } + } catch (error) { + if (loadVersion === this.loadVersion) this.showError(error, 'Ansicht konnte nicht geladen werden.'); + } finally { + if (loadVersion === this.loadVersion) { + this.state.loading = false; + this.render(); + } + } } - async loadMonth() { - this.state.monthPlan = await this.repository.monthPlan(this.state.selectedTeamCode, this.state.month); - const updatedTeam = this.state.monthPlan.team; + async loadMonth(loadVersion = ++this.loadVersion) { + const teamCode = this.state.selectedTeamCode; + const month = this.state.month; + const monthPlan = await this.repository.monthPlan(teamCode, month); + if (loadVersion !== this.loadVersion || teamCode !== this.state.selectedTeamCode || month !== this.state.month || this.state.activeView !== 'month') { + return false; + } + this.state.monthPlan = monthPlan; + const updatedTeam = monthPlan.team; this.state.teams = this.state.teams.map(team => team.code === updatedTeam.code ? updatedTeam : team); + return true; } async selectTeam(value) { this.state.selectedTeamCode = value; this.state.monthPlan = null; await this.reloadActive(); } - async selectMonth(value) { this.state.month = value; if (this.state.activeView === 'month') await this.reloadActive(); } + async selectMonth(value) { + this.state.month = value; + this.state.monthPlan = null; + if (this.state.activeView === 'month') await this.reloadActive(); + } async selectView(value) { this.state.activeView = value; this.render(); await this.reloadActive(); } async handleAction(button) { + if (button.disabled) return; + button.disabled = true; + let mutationCompleted = false; try { const action = button.dataset.action; const team = this.state.selectedTeamCode; const month = this.state.month; const slot = button.dataset.slotId; if (action === 'add-self') await this.repository.addSelf(team, month, slot); else if (action === 'add-selected') { - const select = this.panel.panel.querySelector(`select[data-add-select="${CSS.escape(slot)}"]`); - if (!select || select.disabled || !select.value) return; - await this.repository.addSelected(team, month, slot, select.value); + const targetUid = button.dataset.targetUid || ''; + if (!targetUid) return; + await this.repository.addSelected(team, month, slot, targetUid); } else if (action === 'remove-candidate') await this.repository.removeCandidate(team, month, slot, button.dataset.targetUid || ''); else if (action === 'transition-status') await this.repository.transitionStatus(team, month, button.dataset.targetStatus || ''); else if (action === 'save-note') { const textarea = this.panel.panel.querySelector(`textarea[data-note-date="${CSS.escape(button.dataset.date)}"]`); await this.repository.saveDayNote(team, month, button.dataset.date, textarea ? textarea.value : ''); } else return; + mutationCompleted = true; + this.state.monthPlan = null; await this.loadMonth(); - } catch (error) { this.showError(error, 'Aktion konnte nicht ausgeführt werden.'); } - finally { this.panel.render(this.state, this.selectedTeam()); } + } catch (error) { + this.showError( + error, + mutationCompleted + ? 'Die Änderung wurde gespeichert, aber der Monatsplan konnte nicht neu geladen werden.' + : 'Aktion konnte nicht ausgeführt werden.' + ); + } + finally { + button.disabled = false; + this.panel.render(this.state, this.selectedTeam()); + } } async saveSettings(values) { + if (this.settingsSaving) return; + this.settingsSaving = true; + let mutationCompleted = false; try { if (values.shifts.length === 0) throw new Error('Mindestens eine Schicht muss konfiguriert sein.'); await this.repository.saveSettings(this.state.selectedTeamCode, values.displayName, values.meetingDay, values.shifts); + mutationCompleted = true; + this.state.monthPlan = null; this.applyState(await this.repository.state()); this.state.activeView = 'month'; await this.loadMonth(); - this.render(); - } catch (error) { this.showError(error, 'Einstellungen konnten nicht gespeichert werden.'); } + } catch (error) { + this.showError( + error, + mutationCompleted + ? 'Die Einstellungen wurden gespeichert, aber die Ansicht konnte nicht neu geladen werden.' + : 'Einstellungen konnten nicht gespeichert werden.' + ); + } finally { + this.settingsSaving = false; + if (mutationCompleted) this.render(); + } } } diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 84d4a05..e35bb33 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -98,9 +98,9 @@ public function saveTeamSettings( #[NoAdminRequired] public function saveDayNote(string $teamCode, string $month, string $workDate, string $note = ''): DataResponse { - return $this->responder->respond(function () use ($teamCode, $workDate, $note): array { + return $this->responder->respond(function () use ($teamCode, $month, $workDate, $note): array { $team = $this->teamAccess->assertCanCoordinate($teamCode); - $this->scheduleService->saveDayNote($team, $workDate, $note, $this->teamAccess->currentUserId()); + $this->scheduleService->saveDayNote($team, $month, $workDate, $note, $this->teamAccess->currentUserId()); return ['ok' => true]; }, [$this->logger, 'error'], 'save_day_note', [ diff --git a/lib/Controller/DemoAdminController.php b/lib/Controller/DemoAdminController.php index a4a6d70..66f47f4 100644 --- a/lib/Controller/DemoAdminController.php +++ b/lib/Controller/DemoAdminController.php @@ -17,13 +17,14 @@ /** Zweck: Startet den Assistenzplaner-Demo-Pack ausschließlich mit Admin- und CSRF-Schutz. */ final class DemoAdminController extends Controller { public function __construct(IRequest $request, private IUserSession $session, private IGroupManager $groups, private PlanerDemoPackService $demoPack, private LoggerInterface $logger) { parent::__construct(Application::APP_ID, $request); } - public function install(): JSONResponse { + public function install(mixed $confirmed = false): JSONResponse { if (!$this->isAdmin()) return new JSONResponse(['error' => 'Keine Berechtigung.'], Http::STATUS_FORBIDDEN); + if ($confirmed !== true) return new JSONResponse(['error' => 'Die Installation muss ausdrücklich bestätigt werden.'], Http::STATUS_BAD_REQUEST); try { return new JSONResponse(['result' => $this->demoPack->install()]); } catch (\Throwable $error) { $this->logger->error('Assistenzplaner-Demo-Pack konnte nicht installiert werden.', ['exception' => $error]); - return new JSONResponse(['error' => $error->getMessage()], Http::STATUS_BAD_REQUEST); + return new JSONResponse(['error' => 'Demo-Daten konnten nicht installiert werden.'], Http::STATUS_INTERNAL_SERVER_ERROR); } } private function isAdmin(): bool { diff --git a/lib/Migration/Version000004Date202608090001.php b/lib/Migration/Version000004Date202608090001.php new file mode 100644 index 0000000..9eb9e82 --- /dev/null +++ b/lib/Migration/Version000004Date202608090001.php @@ -0,0 +1,33 @@ +hasTable('adp_month_plans')) { + return $schema; + } + + $table = $schema->getTable('adp_month_plans'); + if ($table->hasColumn('revision')) { + return $schema; + } + + $table->addColumn('revision', Types::INTEGER, [ + 'notnull' => true, + 'default' => 0, + ]); + + return $schema; + } +} diff --git a/lib/Model/Assistant.php b/lib/Model/Assistant.php index 6e6e64a..ca21af4 100644 --- a/lib/Model/Assistant.php +++ b/lib/Model/Assistant.php @@ -28,10 +28,11 @@ protected static function fromArray(array $assistant): self { public static function fromUser($user, bool $isEb): self { $uid = $user->getUID(); + $displayName = (string)$user->getDisplayName(); return new self( $uid, - $user->getDisplayName() ?: $uid, + $displayName === '' ? $uid : $displayName, $isEb, !$isEb ); diff --git a/lib/Model/ShiftCandidate.php b/lib/Model/ShiftCandidate.php index 9c625b9..7300ae8 100644 --- a/lib/Model/ShiftCandidate.php +++ b/lib/Model/ShiftCandidate.php @@ -37,7 +37,6 @@ public function toArray(array $assistantLabels = [], string $currentUid = ''): a 'id' => $this->id, 'uid' => $this->assistantUid, 'displayName' => $assistantLabels[$this->assistantUid] ?? ($this->displayName !== '' ? $this->displayName : $this->assistantUid), - 'createdByUid' => $this->createdByUid, 'isSelf' => $this->isSelf || $this->assistantUid === $currentUid, ]; } diff --git a/lib/Model/Team.php b/lib/Model/Team.php index 73b74fa..c7444c0 100644 --- a/lib/Model/Team.php +++ b/lib/Model/Team.php @@ -48,7 +48,13 @@ public function assistants(): array { } public function assistantsArray(): array { - return array_map(static fn(Assistant $assistant): array => $assistant->toArray(), $this->assistants); + return array_values(array_map( + static fn(Assistant $assistant): array => $assistant->toArray(), + array_filter( + $this->assistants, + static fn(Assistant $assistant): bool => $assistant->canReceiveShifts + ) + )); } public function assistantLabelMap(): array { diff --git a/lib/Repository/ShiftPlanRepository.php b/lib/Repository/ShiftPlanRepository.php index 504307b..bf9b27c 100644 --- a/lib/Repository/ShiftPlanRepository.php +++ b/lib/Repository/ShiftPlanRepository.php @@ -5,6 +5,7 @@ namespace OCA\AdPlaner\Repository; use DateTimeImmutable; +use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -14,6 +15,25 @@ public function __construct( ) { } + public function transactional(callable $operation): mixed { + if ($this->db->inTransaction()) { + return $operation(); + } + + $this->db->beginTransaction(); + try { + $result = $operation(); + $this->db->commit(); + + return $result; + } catch (\Throwable $exception) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + throw $exception; + } + } + public function findSlotsForMonth(string $teamCode, string $month): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') @@ -117,7 +137,13 @@ public function addCandidate(int $slotId, string $assistantUid, string $createdB 'created_by_uid' => $qb->createNamedParameter($createdByUid), 'created_at' => $qb->createNamedParameter(new DateTimeImmutable(), IQueryBuilder::PARAM_DATETIME_IMMUTABLE), ]); - $qb->executeStatement(); + try { + $qb->executeStatement(); + } catch (Exception $exception) { + if ($exception->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $exception; + } + } } public function removeCandidate(int $slotId, string $assistantUid): void { @@ -161,8 +187,14 @@ public function saveDayNote(string $teamCode, string $workDate, string $note, st 'updated_by_uid' => $qb->createNamedParameter($updatedByUid), 'updated_at' => $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), ]); - $qb->executeStatement(); - return; + try { + $qb->executeStatement(); + return; + } catch (Exception $exception) { + if ($exception->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $exception; + } + } } $qb = $this->db->getQueryBuilder(); @@ -175,6 +207,14 @@ public function saveDayNote(string $teamCode, string $workDate, string $note, st $qb->executeStatement(); } + public function deleteDayNote(string $teamCode, string $workDate): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete('adp_day_notes') + ->where($qb->expr()->eq('team_code', $qb->createNamedParameter($teamCode))) + ->andWhere($qb->expr()->eq('work_date', $qb->createNamedParameter($workDate))); + $qb->executeStatement(); + } + public function monthStatus(string $teamCode, string $month): ?string { $qb = $this->db->getQueryBuilder(); $qb->select('status') @@ -186,6 +226,58 @@ public function monthStatus(string $teamCode, string $month): ?string { return $row === false ? null : (string)$row['status']; } + public function ensureMonthStatus(string $teamCode, string $month, string $updatedByUid): void { + if ($this->monthStatus($teamCode, $month) !== null) { + return; + } + + $qb = $this->db->getQueryBuilder(); + $qb->insert('adp_month_plans')->values([ + 'team_code' => $qb->createNamedParameter($teamCode), + 'plan_month' => $qb->createNamedParameter($month), + 'status' => $qb->createNamedParameter('draft'), + 'revision' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT), + 'updated_by_uid' => $qb->createNamedParameter($updatedByUid), + 'updated_at' => $qb->createNamedParameter(new DateTimeImmutable(), IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + ]); + + try { + $qb->executeStatement(); + } catch (Exception $exception) { + if ($exception->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $exception; + } + } + } + + public function lockMonthStatus( + string $teamCode, + string $month, + array $expectedStatuses + ): ?string { + $expectedStatuses = array_values(array_unique(array_map('strval', $expectedStatuses))); + if ($expectedStatuses === []) { + return null; + } + + $qb = $this->db->getQueryBuilder(); + $statusConditions = array_map( + fn(string $status) => $qb->expr()->eq('status', $qb->createNamedParameter($status)), + $expectedStatuses + ); + $qb->update('adp_month_plans') + ->set('revision', $qb->createFunction('revision + 1')) + ->where($qb->expr()->eq('team_code', $qb->createNamedParameter($teamCode))) + ->andWhere($qb->expr()->eq('plan_month', $qb->createNamedParameter($month))) + ->andWhere($qb->expr()->orX(...$statusConditions)); + + if ($qb->executeStatement() !== 1) { + return null; + } + + return $this->monthStatus($teamCode, $month); + } + public function transitionMonthStatus( string $teamCode, string $month, @@ -203,6 +295,7 @@ public function transitionMonthStatus( 'team_code' => $qb->createNamedParameter($teamCode), 'plan_month' => $qb->createNamedParameter($month), 'status' => $qb->createNamedParameter($targetStatus), + 'revision' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT), 'updated_by_uid' => $qb->createNamedParameter($updatedByUid), 'updated_at' => $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), ]); diff --git a/lib/Repository/TeamSettingsRepository.php b/lib/Repository/TeamSettingsRepository.php index 26d4b95..693b340 100644 --- a/lib/Repository/TeamSettingsRepository.php +++ b/lib/Repository/TeamSettingsRepository.php @@ -5,6 +5,7 @@ namespace OCA\AdPlaner\Repository; use DateTimeImmutable; +use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -43,8 +44,14 @@ public function save(string $teamCode, string $displayName, array $settings): vo 'created_at' => $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), 'updated_at' => $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), ]); - $qb->executeStatement(); - return; + try { + $qb->executeStatement(); + return; + } catch (Exception $exception) { + if ($exception->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $exception; + } + } } $qb = $this->db->getQueryBuilder(); diff --git a/lib/Service/PlanningHintService.php b/lib/Service/PlanningHintService.php index c31327f..e95f6cc 100644 --- a/lib/Service/PlanningHintService.php +++ b/lib/Service/PlanningHintService.php @@ -12,7 +12,10 @@ /** Aggregiert optionale Abwesenheiten und Kalenderbelegungen ohne Schreibwirkung oder fremde Detaildaten. */ class PlanningHintService { - public function __construct(private IEventDispatcher $events) {} + public function __construct( + private IEventDispatcher $events, + private AdPlanerLogger $logger, + ) {} /** @param list $employeeUids * @return array> @@ -32,31 +35,11 @@ public function forMonth(string $month, array $employeeUids): array { $end = $start->modify('+1 month'); $hints = []; - $absenceEvent = new AbsenceQueryEvent($start, $end, $employeeUids); - $this->events->dispatchTyped($absenceEvent); - foreach ($absenceEvent->absences() as $absence) { - $payload = $absence->toArray(); - $this->appendForDays( - $hints, - new DateTimeImmutable($payload['start']), - new DateTimeImmutable($payload['end']), - $start, - $end, - [ - 'employeeUid' => $payload['employeeUid'], - 'type' => 'absence', - 'marker' => $payload['marker'], - 'label' => 'Urlaub', - 'blocks' => false, - ] - ); - } - - foreach ($employeeUids as $employeeUid) { - $conflictEvent = new ScheduleConflictQueryEvent($employeeUid, $start, $end); - $this->events->dispatchTyped($conflictEvent); - foreach ($conflictEvent->conflicts() as $conflict) { - $payload = $conflict->toArray(); + try { + $absenceEvent = new AbsenceQueryEvent($start, $end, $employeeUids); + $this->events->dispatchTyped($absenceEvent); + foreach ($absenceEvent->absences() as $absence) { + $payload = $absence->toArray(); $this->appendForDays( $hints, new DateTimeImmutable($payload['start']), @@ -64,14 +47,42 @@ public function forMonth(string $month, array $employeeUids): array { $start, $end, [ - 'employeeUid' => $employeeUid, - 'type' => 'calendar', - 'marker' => 'K', - 'label' => $payload['type'] === 'shift' ? 'Dienst' : 'Termin', + 'employeeUid' => $payload['employeeUid'], + 'type' => 'absence', + 'marker' => $payload['marker'], + 'label' => 'Urlaub', 'blocks' => false, ] ); } + } catch (\Throwable $error) { + $this->logger->error('planning_hint_absences', $error, ['month' => $month]); + } + + foreach ($employeeUids as $employeeUid) { + try { + $conflictEvent = new ScheduleConflictQueryEvent($employeeUid, $start, $end); + $this->events->dispatchTyped($conflictEvent); + foreach ($conflictEvent->conflicts() as $conflict) { + $payload = $conflict->toArray(); + $this->appendForDays( + $hints, + new DateTimeImmutable($payload['start']), + new DateTimeImmutable($payload['end']), + $start, + $end, + [ + 'employeeUid' => $employeeUid, + 'type' => 'calendar', + 'marker' => 'K', + 'label' => $payload['type'] === 'shift' ? 'Dienst' : 'Termin', + 'blocks' => false, + ] + ); + } + } catch (\Throwable $error) { + $this->logger->error('planning_hint_calendar', $error, ['month' => $month]); + } } foreach ($hints as &$dayHints) { diff --git a/lib/Service/ScheduleService.php b/lib/Service/ScheduleService.php index 8aafe3d..f52cf60 100644 --- a/lib/Service/ScheduleService.php +++ b/lib/Service/ScheduleService.php @@ -13,6 +13,7 @@ class ScheduleService { private const STATUS_DRAFT = 'draft'; private const STATUS_PLANNED = 'planned'; private const STATUS_APPROVED = 'approved'; + private const MAX_DAY_NOTE_LENGTH = 2000; public function __construct( private ShiftPlanStore $store, @@ -26,11 +27,28 @@ public function monthPlan(Team $team, string $month, string $currentUid): array $month = $this->shiftConfig->normalizeMonth($month); $status = $this->store->monthStatus($team->code, $month); if ($status !== self::STATUS_APPROVED) { - $this->ensureMonthSlots($team, $month); + $this->store->ensureMonthStatus($team->code, $month, $currentUid); + $status = $this->store->transactional(function () use ($team, $month): string { + $lockedStatus = $this->store->lockMonthStatus( + $team->code, + $month, + [self::STATUS_DRAFT, self::STATUS_PLANNED] + ); + if ($lockedStatus === null) { + return $this->store->monthStatus($team->code, $month); + } + + $this->ensureMonthSlots($team, $month); + + return $lockedStatus; + }); } $slots = $this->store->slotsForMonth($team->code, $month); $enabledSlots = array_values(array_filter($slots, static fn(ShiftSlot $slot): bool => $slot->enabled)); + $segments = $status === self::STATUS_APPROVED + ? $this->segmentsFromFrozenSlots($enabledSlots) + : array_values(array_filter($this->shiftConfig->segments($team->settings), static fn(array $segment): bool => $segment['enabled'])); $candidatesBySlot = $this->store->candidatesForSlotIds(array_map(static fn(ShiftSlot $slot): int => $slot->id, $enabledSlots)); $assistantLabels = $team->assistantLabelMap(); $assignableUids = $team->assignableAssistantUidMap(); @@ -57,7 +75,9 @@ public function monthPlan(Team $team, string $month, string $currentUid): array 'slots' => $slotsByDate[$date] ?? [], 'note' => isset($notes[$date]) ? $notes[$date]->note : '', 'hints' => array_map(static function (array $hint) use ($assistantLabels): array { - $hint['displayName'] = $assistantLabels[$hint['employeeUid']] ?? $hint['employeeUid']; + $employeeUid = (string)($hint['employeeUid'] ?? ''); + $hint['displayName'] = $assistantLabels[$employeeUid] ?? ''; + unset($hint['employeeUid']); return $hint; }, $hints[$date] ?? []), ]; @@ -67,15 +87,13 @@ public function monthPlan(Team $team, string $month, string $currentUid): array 'team' => $team->toArray(), 'month' => $month, 'status' => $status, - 'segments' => array_values(array_filter($this->shiftConfig->segments($team->settings), static fn(array $segment): bool => $segment['enabled'])), + 'segments' => $segments, 'days' => $days, ]; } public function addCandidate(Team $team, string $month, int $slotId, string $targetUid, string $currentUid): void { $month = $this->shiftConfig->normalizeMonth($month); - $this->assertMonthMutable($team->code, $month); - $slot = $this->requireSlot($slotId, $team->code, $month); if ($targetUid === '') { if ($team->isEb) { throw new \DomainException('Bitte eine Assistenzkraft auswählen.'); @@ -87,29 +105,44 @@ public function addCandidate(Team $team, string $month, int $slotId, string $tar $this->assertCandidateMutationAllowed($team, $targetUid, $currentUid); $this->assertAssignableAssistantInTeam($team, $targetUid); - $this->store->addCandidate($slot->id, $targetUid, $currentUid); + $this->withMutableMonth($team->code, $month, $currentUid, function () use ($slotId, $team, $month, $targetUid, $currentUid): void { + $slot = $this->requireSlot($slotId, $team->code, $month); + $this->store->addCandidate($slot->id, $targetUid, $currentUid); + }); } public function removeCandidate(Team $team, string $month, int $slotId, string $targetUid, string $currentUid): void { $month = $this->shiftConfig->normalizeMonth($month); - $this->assertMonthMutable($team->code, $month); - $slot = $this->requireSlot($slotId, $team->code, $month); $targetUid = $targetUid === '' ? $currentUid : $targetUid; $this->assertCandidateMutationAllowed($team, $targetUid, $currentUid); $this->assertAssignableAssistantInTeam($team, $targetUid); - $this->store->removeCandidate($slot->id, $targetUid); + $this->withMutableMonth($team->code, $month, $currentUid, function () use ($slotId, $team, $month, $targetUid): void { + $slot = $this->requireSlot($slotId, $team->code, $month); + $this->store->removeCandidate($slot->id, $targetUid); + }); } - public function saveDayNote(Team $team, string $workDate, string $note, string $currentUid): void { + public function saveDayNote(Team $team, string $month, string $workDate, string $note, string $currentUid): void { if (!$team->isEb) { throw new \DomainException('Nur die Einsatzbegleitung darf Bemerkungen bearbeiten.'); } + $month = $this->shiftConfig->normalizeMonth($month); $workDate = $this->shiftConfig->normalizeDate($workDate); - $this->assertMonthMutable($team->code, substr($workDate, 0, 7)); - $this->store->saveDayNote($team->code, $workDate, trim($note), $currentUid); + if (!str_starts_with($workDate, $month . '-')) { + throw new \DomainException('Das Datum gehört nicht zum ausgewählten Planungsmonat.'); + } + $note = $this->normalizeDayNote($note); + $this->withMutableMonth($team->code, $month, $currentUid, function () use ($team, $workDate, $note, $currentUid): void { + if ($note === '') { + $this->store->deleteDayNote($team->code, $workDate); + return; + } + + $this->store->saveDayNote($team->code, $workDate, $note, $currentUid); + }); } public function transitionMonthStatus(Team $team, string $month, string $targetStatus, string $currentUid): string { @@ -128,11 +161,22 @@ public function transitionMonthStatus(Team $team, string $month, string $targetS if (!in_array($targetStatus, $allowedTargets[$currentStatus] ?? [], true)) { throw new \DomainException('Dieser Planstatuswechsel ist nicht erlaubt.'); } - if (!$this->store->transitionMonthStatus($team->code, $month, $currentStatus, $targetStatus, $currentUid)) { - throw new \DomainException('Der Planstatus wurde zwischenzeitlich geändert. Bitte neu laden.'); - } - return $targetStatus; + $this->store->ensureMonthStatus($team->code, $month, $currentUid); + return $this->store->transactional(function () use ($team, $month, $targetStatus, $currentUid, $currentStatus): string { + $lockedStatus = $this->store->lockMonthStatus($team->code, $month, [$currentStatus]); + if ($lockedStatus !== $currentStatus) { + throw new \DomainException('Der Planstatus wurde zwischenzeitlich geändert. Bitte neu laden.'); + } + if ($targetStatus === self::STATUS_APPROVED) { + $this->ensureMonthSlots($team, $month); + } + if (!$this->store->transitionMonthStatus($team->code, $month, $currentStatus, $targetStatus, $currentUid)) { + throw new \DomainException('Der Planstatus wurde zwischenzeitlich geändert. Bitte neu laden.'); + } + + return $targetStatus; + }); } private function ensureMonthSlots(Team $team, string $month): void { @@ -190,6 +234,25 @@ private function ensureMonthSlots(Team $team, string $month): void { } } + /** @param list $slots */ + private function segmentsFromFrozenSlots(array $slots): array { + $segments = []; + foreach ($slots as $slot) { + if (isset($segments[$slot->segmentKey])) { + continue; + } + $segments[$slot->segmentKey] = [ + 'key' => $slot->segmentKey, + 'label' => $slot->label, + 'startsAt' => $slot->startsAt, + 'endsAt' => $slot->endsAt, + 'enabled' => true, + ]; + } + + return array_values($segments); + } + private function requireSlot(int $slotId, string $teamCode, string $month): ShiftSlot { $slot = $this->store->slotForMonth($slotId, $teamCode, $month); if ($slot === null || !$slot->enabled) { @@ -229,9 +292,33 @@ private function assertAssignableAssistantInTeam(Team $team, string $assistantUi } } - private function assertMonthMutable(string $teamCode, string $month): void { - if ($this->store->monthStatus($teamCode, $month) === self::STATUS_APPROVED) { - throw new \DomainException('Der genehmigte Monatsplan ist gegen Änderungen gesperrt.'); + private function normalizeDayNote(string $note): string { + $note = trim($note); + $length = preg_match_all('/./us', $note); + if ($length === false) { + throw new \InvalidArgumentException('Bemerkungen müssen gültiges UTF-8 enthalten.'); + } + if ($length > self::MAX_DAY_NOTE_LENGTH) { + throw new \InvalidArgumentException('Bemerkungen dürfen höchstens 2.000 Zeichen lang sein.'); } + + return $note; + } + + private function withMutableMonth(string $teamCode, string $month, string $currentUid, callable $operation): mixed { + $this->store->ensureMonthStatus($teamCode, $month, $currentUid); + + return $this->store->transactional(function () use ($teamCode, $month, $operation): mixed { + $status = $this->store->lockMonthStatus( + $teamCode, + $month, + [self::STATUS_DRAFT, self::STATUS_PLANNED] + ); + if ($status === null) { + throw new \DomainException('Der genehmigte Monatsplan ist gegen Änderungen gesperrt.'); + } + + return $operation(); + }); } } diff --git a/lib/Service/ShiftConfigService.php b/lib/Service/ShiftConfigService.php index d6135e2..2cfd7aa 100644 --- a/lib/Service/ShiftConfigService.php +++ b/lib/Service/ShiftConfigService.php @@ -111,6 +111,10 @@ private function normalizeShifts(array $shifts): array { throw new \InvalidArgumentException('Mindestens eine Schicht muss konfiguriert sein.'); } + if (!array_is_list($shifts)) { + throw new \InvalidArgumentException('Schichten müssen als Liste übergeben werden.'); + } + if (count($shifts) > 64) { throw new \InvalidArgumentException('Höchstens 64 Schichten können konfiguriert werden.'); } @@ -129,10 +133,14 @@ private function normalizeShifts(array $shifts): array { $label = trim((string)($shift['label'] ?? '')); if ($label === '') { - $label = 'Schicht ' . ($index + 1); + throw new \InvalidArgumentException('Schichtnamen dürfen nicht leer sein.'); } - if (strlen($label) > 64) { + $labelLength = preg_match_all('/./us', $label); + if ($labelLength === false) { + throw new \InvalidArgumentException('Schichtnamen müssen gültiges UTF-8 enthalten.'); + } + if ($labelLength > 64) { throw new \InvalidArgumentException('Schichtnamen dürfen höchstens 64 Zeichen lang sein.'); } @@ -183,14 +191,22 @@ private function normalizeBoolean(mixed $value): bool { } if (is_int($value)) { - return $value !== 0; + if ($value === 0 || $value === 1) { + return $value === 1; + } } if (is_string($value)) { - return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); + $value = strtolower(trim($value)); + if (in_array($value, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($value, ['0', 'false', 'no', 'off'], true)) { + return false; + } } - return (bool)$value; + throw new \InvalidArgumentException('Der Aktivstatus einer Schicht muss eindeutig wahr oder falsch sein.'); } private function shift(string $key, string $label, string $startsAt, string $endsAt, bool $enabled): array { diff --git a/lib/Service/TeamAccessService.php b/lib/Service/TeamAccessService.php index 1820024..5d22171 100644 --- a/lib/Service/TeamAccessService.php +++ b/lib/Service/TeamAccessService.php @@ -62,7 +62,7 @@ public function teamsForCurrentUser(): array { ))); } - public function teamForCode(string $teamCode): ?Team { + private function teamForCode(string $teamCode): ?Team { $teamCode = $this->normalizeTeamCode($teamCode); $groupName = $this->teamGroupName($teamCode); $group = $this->groupManager->get($groupName); @@ -84,8 +84,13 @@ public function teamForCode(string $teamCode): ?Team { } public function assertTeamAccess(string $teamCode): Team { + $teamCode = $this->normalizeTeamCode($teamCode); + if (!$this->currentUserInGroup($this->teamGroupName($teamCode))) { + throw new \DomainException('Kein Zugriff auf dieses Assistenzteam.'); + } + $team = $this->teamForCode($teamCode); - if ($team === null || !$this->currentUserInGroup($team->groupName)) { + if ($team === null) { throw new \DomainException('Kein Zugriff auf dieses Assistenzteam.'); } @@ -133,6 +138,10 @@ public function assistantLabelMap(array $assistants): array { private function assistantsForGroup($group): array { $assistants = []; foreach ($group->getUsers() as $user) { + if (!$user->isEnabled()) { + continue; + } + $isEb = $this->userHasEbGroup($user); $assistants[] = Assistant::fromUser($user, $isEb); } diff --git a/lib/Store/ShiftPlanStore.php b/lib/Store/ShiftPlanStore.php index 8f3ef48..9a1ab9d 100644 --- a/lib/Store/ShiftPlanStore.php +++ b/lib/Store/ShiftPlanStore.php @@ -15,6 +15,10 @@ public function __construct( ) { } + public function transactional(callable $operation): mixed { + return $this->repository->transactional($operation); + } + public function slotsForMonth(string $teamCode, string $month): array { return ShiftSlot::get_all($this->repository->findSlotsForMonth($teamCode, $month)); } @@ -49,6 +53,14 @@ public function monthStatus(string $teamCode, string $month): string { return $this->repository->monthStatus($teamCode, $month) ?? 'draft'; } + public function ensureMonthStatus(string $teamCode, string $month, string $updatedByUid): void { + $this->repository->ensureMonthStatus($teamCode, $month, $updatedByUid); + } + + public function lockMonthStatus(string $teamCode, string $month, array $expectedStatuses): ?string { + return $this->repository->lockMonthStatus($teamCode, $month, $expectedStatuses); + } + public function transitionMonthStatus( string $teamCode, string $month, @@ -102,4 +114,8 @@ public function removeCandidate(int $slotId, string $assistantUid): void { public function saveDayNote(string $teamCode, string $workDate, string $note, string $updatedByUid): void { $this->repository->saveDayNote($teamCode, $workDate, $note, $updatedByUid); } + + public function deleteDayNote(string $teamCode, string $workDate): void { + $this->repository->deleteDayNote($teamCode, $workDate); + } } diff --git a/lib/Store/TeamSettingsStore.php b/lib/Store/TeamSettingsStore.php index b85af14..d066d18 100644 --- a/lib/Store/TeamSettingsStore.php +++ b/lib/Store/TeamSettingsStore.php @@ -28,10 +28,14 @@ public function forTeam(string $teamCode): TeamSettings { if (!is_array($decoded)) { $decoded = []; } + $displayName = trim((string)($row['display_name'] ?? '')); + if ($displayName === '') { + $displayName = $this->defaultDisplayName($teamCode); + } return new TeamSettings( $teamCode, - (string)($row['display_name'] ?: $this->defaultDisplayName($teamCode)), + $displayName, $this->shiftConfig->normalize($decoded) ); } @@ -39,7 +43,14 @@ public function forTeam(string $teamCode): TeamSettings { public function save(string $teamCode, string $displayName, array $config): TeamSettings { $displayName = trim($displayName); if ($displayName === '') { - $displayName = $this->defaultDisplayName($teamCode); + throw new \InvalidArgumentException('Der Anzeigename darf nicht leer sein.'); + } + $displayNameLength = preg_match_all('/./us', $displayName); + if ($displayNameLength === false) { + throw new \InvalidArgumentException('Der Anzeigename muss gültiges UTF-8 enthalten.'); + } + if ($displayNameLength > 255) { + throw new \InvalidArgumentException('Der Anzeigename darf höchstens 255 Zeichen lang sein.'); } $normalized = $this->shiftConfig->normalize($config); diff --git a/templates/index.php b/templates/index.php index eb3292e..5486d52 100644 --- a/templates/index.php +++ b/templates/index.php @@ -39,7 +39,7 @@ Monat - + diff --git a/tests/AccessSmokeContractTest.php b/tests/AccessSmokeContractTest.php new file mode 100644 index 0000000..6819f3c --- /dev/null +++ b/tests/AccessSmokeContractTest.php @@ -0,0 +1,24 @@ + \$teamCode]]", "['adp_month_plans', ['team_code' => \$teamCode]]"] as $contract) { + if (!str_contains($probe, $contract)) { + throw new RuntimeException('Der Cleanup-Probe entfernt nicht alle durch initiale Browserloads erzeugten Testmonate.'); + } +} +if (str_contains($shell, 'app:disable') || str_contains($shell, 'app:enable')) { + throw new RuntimeException('Die Browser-Abnahme darf den Nextcloud-App-Zustand nicht verändern.'); +} + +echo 'AdPlaner browser DDEV smoke contract test passed' . PHP_EOL; diff --git a/tests/Controller/ControllerAttributeSmokeTest.php b/tests/Controller/ControllerAttributeSmokeTest.php index de8883d..c969a81 100644 --- a/tests/Controller/ControllerAttributeSmokeTest.php +++ b/tests/Controller/ControllerAttributeSmokeTest.php @@ -35,8 +35,7 @@ final class Application { } namespace { - require __DIR__ . '/../../lib/Controller/PageController.php'; - require __DIR__ . '/../../lib/Controller/ApiController.php'; + require_once dirname(__DIR__) . '/bootstrap.php'; use OCA\AdPlaner\Controller\ApiController; use OCA\AdPlaner\Controller\PageController; diff --git a/tests/Controller/DemoAdminControllerTest.php b/tests/Controller/DemoAdminControllerTest.php new file mode 100644 index 0000000..eb7c859 --- /dev/null +++ b/tests/Controller/DemoAdminControllerTest.php @@ -0,0 +1,138 @@ +data; } + public function getStatus(): int { return $this->status; } + } + } +} + +namespace OCP { + if (!interface_exists(IRequest::class)) { + interface IRequest {} + } + if (!interface_exists(IUserSession::class)) { + interface IUserSession { public function getUser(); } + } + if (!interface_exists(IGroupManager::class)) { + interface IGroupManager { public function isAdmin($uid); } + } +} + +namespace Psr\Log { + if (!interface_exists(LoggerInterface::class)) { + interface LoggerInterface { public function error(string|\Stringable $message, array $context = []): void; } + } +} + +namespace OCA\AdPlaner\AppInfo { + if (!class_exists(Application::class)) { + final class Application { public const APP_ID = 'adplaner'; } + } +} + +namespace OCA\AdPlaner\Service { + if (!class_exists(PlanerDemoPackService::class)) { + class PlanerDemoPackService { + public int $installCalls = 0; + public bool $throwOnInstall = false; + public function install(): array { + $this->installCalls++; + if ($this->throwOnInstall) { + throw new \RuntimeException('SQL password=synthetic-secret'); + } + return ['accounts' => [], 'teams' => ['A', 'B', 'C']]; + } + } + } +} + +namespace { + require_once dirname(__DIR__) . '/bootstrap.php'; + + use OCA\AdPlaner\Controller\DemoAdminController; + use OCA\AdPlaner\Service\PlanerDemoPackService; + use OCP\IGroupManager; + use OCP\IRequest; + use OCP\IUserSession; + use Psr\Log\LoggerInterface; + + $request = new class implements IRequest {}; + $session = new class implements IUserSession { + public function getUser(): object { + return new class { + public function getUID(): string { return 'admin-test'; } + }; + } + }; + $groups = new class implements IGroupManager { + public bool $admin = true; + public function isAdmin($uid): bool { return $this->admin && $uid === 'admin-test'; } + }; + $logger = new class implements LoggerInterface { + public array $errors = []; + public function error(string|\Stringable $message, array $context = []): void { + $this->errors[] = compact('message', 'context'); + } + }; + $demoPack = new PlanerDemoPackService(); + $controller = new DemoAdminController($request, $session, $groups, $demoPack, $logger); + + $unconfirmed = $controller->install(false); + if ($unconfirmed->getStatus() !== 400 || $demoPack->installCalls !== 0) { + throw new RuntimeException('Eine unbestätigte Demo-Installation muss ohne Mutation mit HTTP 400 abgewiesen werden.'); + } + + $confirmed = $controller->install(true); + if ($confirmed->getStatus() !== 200 || $demoPack->installCalls !== 1 || ($confirmed->getData()['result']['teams'] ?? []) !== ['A', 'B', 'C']) { + throw new RuntimeException('Eine bestätigte Admin-Installation muss den Demo-Service genau einmal ausführen.'); + } + + $controller->install(false); + if ($demoPack->installCalls !== 1) { + throw new RuntimeException('Jede weitere Demo-Installation muss erneut ausdrücklich bestätigt werden.'); + } + + foreach (['true', '1', 1, null] as $manipulatedConfirmation) { + $manipulated = $controller->install($manipulatedConfirmation); + if ($manipulated->getStatus() !== 400 || $demoPack->installCalls !== 1) { + throw new RuntimeException('Nur das JSON-Boolean true darf die Demo-Installation bestätigen.'); + } + } + + $groups->admin = false; + $forbidden = $controller->install(true); + if ($forbidden->getStatus() !== 403 || $demoPack->installCalls !== 1) { + throw new RuntimeException('Eine bestätigte Nicht-Admin-Anfrage muss ohne Mutation mit HTTP 403 abgewiesen werden.'); + } + + $groups->admin = true; + $demoPack->throwOnInstall = true; + $failed = $controller->install(true); + if ($failed->getStatus() !== 500 || ($failed->getData()['error'] ?? '') !== 'Demo-Daten konnten nicht installiert werden.') { + throw new RuntimeException('Interne Demo-Fehler müssen ohne technische Details als generische HTTP-500-Antwort erscheinen.'); + } + if (str_contains(json_encode($failed->getData()), 'synthetic-secret') || count($logger->errors) !== 1) { + throw new RuntimeException('Interne Fehlermeldungen dürfen nur im Serverlog und nie in der API-Antwort landen.'); + } + + echo 'AdPlaner demo admin controller tests passed' . PHP_EOL; +} diff --git a/tests/IntegrationCapabilityListenerTest.php b/tests/IntegrationCapabilityListenerTest.php index cf1e852..4eb239f 100644 --- a/tests/IntegrationCapabilityListenerTest.php +++ b/tests/IntegrationCapabilityListenerTest.php @@ -6,9 +6,7 @@ namespace OCA\AdPlaner\AppInfo { final class Application { public const APP_ID = 'adplaner'; } } namespace { - require_once __DIR__ . '/../../localbase/lib/Integration/AdIntegrationCapabilities.php'; - require_once __DIR__ . '/../../localbase/lib/Integration/IntegrationCapabilityQueryEvent.php'; - require_once __DIR__ . '/../lib/Listener/IntegrationCapabilityQueryListener.php'; + require_once __DIR__ . '/bootstrap.php'; use OCA\AdPlaner\Listener\IntegrationCapabilityQueryListener; use OCA\LocalBase\Integration\AdIntegrationCapabilities; diff --git a/tests/MigrationContractTest.php b/tests/MigrationContractTest.php index 67c5337..f4d4e53 100644 --- a/tests/MigrationContractTest.php +++ b/tests/MigrationContractTest.php @@ -21,4 +21,21 @@ throw new RuntimeException('Bestehende Monatspläne erhalten keinen sicheren Entwurfsstatus.'); } +$revisionMigration = __DIR__ . '/../lib/Migration/Version000004Date202608090001.php'; +if (!is_file($revisionMigration)) { + throw new RuntimeException('Die additive Revisionsmigration für atomare Monatsänderungen fehlt.'); +} +$revisionSource = file_get_contents($revisionMigration); +if ($revisionSource === false + || !str_contains($revisionSource, "hasColumn('revision')") + || !str_contains($revisionSource, "addColumn('revision', Types::INTEGER") + || !str_contains($revisionSource, "'default' => 0")) { + throw new RuntimeException('Bestehende Monatsstatuszeilen erhalten keine sichere Revisionsnummer ab 0.'); +} + +$info = file_get_contents(__DIR__ . '/../appinfo/info.xml'); +if ($info === false || !str_contains($info, '0.4.0-rc.2')) { + throw new RuntimeException('Die additive Revisionsmigration besitzt keinen neuen App-Versionsauslöser.'); +} + echo "AdPlaner month plan migration contract test passed\n"; diff --git a/tests/MigrationExecutionTest.php b/tests/MigrationExecutionTest.php new file mode 100644 index 0000000..44c3d08 --- /dev/null +++ b/tests/MigrationExecutionTest.php @@ -0,0 +1,130 @@ +columns[$name] = compact('type', 'options'); + if ($name === 'revision') { + $this->revisionAdditions++; + } + if (array_key_exists('default', $options)) { + foreach ($this->rows as &$row) { + $row[$name] ??= $options['default']; + } + unset($row); + } + } + + public function hasColumn(string $name): bool { + return isset($this->columns[$name]); + } + + public function setPrimaryKey(array $columns): void { + $this->primaryKey = $columns; + } + + public function addUniqueIndex(array $columns, string $name): void { + $this->uniqueIndexes[$name] = $columns; + } + } + + final class MigrationSchemaFake implements ISchemaWrapper { + /** @var array */ + public array $tables = []; + + public function hasTable(string $name): bool { + return isset($this->tables[$name]); + } + + public function createTable(string $name): MigrationTableFake { + return $this->tables[$name] = new MigrationTableFake(); + } + + public function getTable(string $name): MigrationTableFake { + return $this->tables[$name]; + } + } + + $run = static function (object $migration, MigrationSchemaFake $schema): void { + $result = $migration->changeSchema( + new MigrationOutputFake(), + static fn(): MigrationSchemaFake => $schema, + [] + ); + assertSameValue($schema, $result, 'A schema migration should return the schema it changed.'); + }; + + $freshSchema = new MigrationSchemaFake(); + $run(new Version000003Date202608080001(), $freshSchema); + $run(new Version000004Date202608090001(), $freshSchema); + $freshTable = $freshSchema->getTable('adp_month_plans'); + assertSameValue(true, $freshTable->hasColumn('status'), 'A fresh migration sequence should create the month status column.'); + assertSameValue(true, $freshTable->hasColumn('revision'), 'A fresh migration sequence should create the revision column.'); + assertSameValue(0, $freshTable->columns['revision']['options']['default'] ?? null, 'A fresh revision column should default to zero.'); + assertSameValue(['team_code', 'plan_month'], $freshTable->uniqueIndexes['adp_month_plan_unique'] ?? null, 'A fresh schema should enforce one status per team and month.'); + + $upgradeSchema = new MigrationSchemaFake(); + $upgradeTable = $upgradeSchema->createTable('adp_month_plans'); + $upgradeTable->addColumn('status', 'string', ['notnull' => true, 'default' => 'draft']); + $upgradeTable->rows = [ + ['status' => 'planned'], + ['status' => 'approved'], + ]; + $run(new Version000004Date202608090001(), $upgradeSchema); + assertSameValue( + [ + ['status' => 'planned', 'revision' => 0], + ['status' => 'approved', 'revision' => 0], + ], + $upgradeTable->rows, + 'Existing month statuses should be preserved and receive revision zero during upgrade.' + ); + + $run(new Version000004Date202608090001(), $upgradeSchema); + assertSameValue(1, $upgradeTable->revisionAdditions, 'Repeating the revision migration must not add the column twice.'); + + $missingBaseSchema = new MigrationSchemaFake(); + $run(new Version000004Date202608090001(), $missingBaseSchema); + assertSameValue(false, $missingBaseSchema->hasTable('adp_month_plans'), 'The revision migration must not invent a missing base table out of sequence.'); + + echo 'AdPlaner migration execution tests passed' . PHP_EOL; +} diff --git a/tests/PrivacyDdevSmokeContractTest.php b/tests/PrivacyDdevSmokeContractTest.php new file mode 100644 index 0000000..9caf5e3 --- /dev/null +++ b/tests/PrivacyDdevSmokeContractTest.php @@ -0,0 +1,25 @@ +reason; + } + } +} + +namespace { + require_once dirname(__DIR__) . '/bootstrap.php'; + + use OCA\AdPlaner\Repository\ShiftPlanRepository; + use OCP\DB\Exception; + use OCP\IDBConnection; + use function OCA\AdPlaner\Tests\assertSameValue; + + final class CandidateResultFake { + public function __construct(private array|false $row = false) {} + + public function fetchAssociative(): array|false { + return $this->row; + } + } + + final class CandidateExpressionFake { + public function eq(mixed $left, mixed $right): array { + return [$left, $right]; + } + + public function orX(mixed ...$conditions): array { + return $conditions; + } + } + + final class CandidateConnectionFake implements IDBConnection { + public int $insertFailureReason = Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION; + public int $insertAttempts = 0; + public int $updateAttempts = 0; + public array|false $statusRow = false; + public array $updatedColumns = []; + + public function getQueryBuilder(): CandidateQueryBuilderFake { + return new CandidateQueryBuilderFake($this); + } + } + + final class CandidateQueryBuilderFake { + private string $operation = 'select'; + private string $table = ''; + + public function __construct(private CandidateConnectionFake $connection) { + } + + public function select(string ...$columns): self { return $this; } + public function from(string $table): self { + $this->table = $table; + return $this; + } + public function where(mixed $condition): self { return $this; } + public function andWhere(mixed $condition): self { return $this; } + public function setMaxResults(int $limit): self { return $this; } + public function expr(): CandidateExpressionFake { return new CandidateExpressionFake(); } + public function createNamedParameter(mixed $value, mixed $type = null): mixed { return $value; } + public function createFunction(string $expression): string { return $expression; } + + public function insert(string $table): self { + $this->operation = 'insert'; + return $this; + } + + public function values(array $values): self { return $this; } + public function update(string $table): self { + $this->operation = 'update'; + $this->table = $table; + return $this; + } + public function set(string $column, mixed $value): self { + $this->connection->updatedColumns[] = $column; + return $this; + } + public function executeQuery(): CandidateResultFake { + return new CandidateResultFake($this->table === 'adp_month_plans' ? $this->connection->statusRow : false); + } + + public function executeStatement(): int { + if ($this->operation === 'update') { + $this->connection->updateAttempts++; + return 1; + } + + $this->connection->insertAttempts++; + throw new Exception($this->connection->insertFailureReason); + } + } + + $connection = new CandidateConnectionFake(); + $repository = new ShiftPlanRepository($connection); + $repository->addCandidate(7, 'assistant-a', 'test-eb'); + assertSameValue(1, $connection->insertAttempts, 'A concurrent duplicate candidate should require only one insert attempt.'); + + $noteConnection = new CandidateConnectionFake(); + $noteRepository = new ShiftPlanRepository($noteConnection); + $noteRepository->saveDayNote('A1', '2026-08-03', 'Neuester Inhalt', 'test-eb'); + assertSameValue(1, $noteConnection->insertAttempts, 'A first day-note save should attempt one insert.'); + assertSameValue(1, $noteConnection->updateAttempts, 'A concurrent first day-note insert must be recovered with one update.'); + + $lockConnection = new CandidateConnectionFake(); + $lockConnection->statusRow = ['status' => 'planned']; + $lockRepository = new ShiftPlanRepository($lockConnection); + assertSameValue('planned', $lockRepository->lockMonthStatus('A1', '2026-08', ['draft', 'planned']), 'A matching month status should be locked.'); + assertSameValue(['revision'], $lockConnection->updatedColumns, 'A technical month lock must not persist viewer identity or overwrite the last functional update time.'); + + $failingConnection = new CandidateConnectionFake(); + $failingConnection->insertFailureReason = Exception::REASON_DRIVER; + $failingRepository = new ShiftPlanRepository($failingConnection); + try { + $failingRepository->addCandidate(7, 'assistant-a', 'test-eb'); + } catch (Exception $exception) { + assertSameValue(Exception::REASON_DRIVER, $exception->getReason(), 'A non-unique candidate database failure must propagate unchanged.'); + echo 'AdPlaner shift plan repository tests passed' . PHP_EOL; + return; + } + + throw new RuntimeException('A non-unique candidate database failure must not be swallowed.'); +} diff --git a/tests/Repository/TeamSettingsRepositoryTest.php b/tests/Repository/TeamSettingsRepositoryTest.php new file mode 100644 index 0000000..e24790e --- /dev/null +++ b/tests/Repository/TeamSettingsRepositoryTest.php @@ -0,0 +1,167 @@ +reason; + } + } +} + +namespace { + require_once dirname(__DIR__) . '/bootstrap.php'; + + use OCA\AdPlaner\Repository\TeamSettingsRepository; + use OCP\DB\Exception; + use OCP\IDBConnection; + use function OCA\AdPlaner\Tests\assertSameValue; + + final class TeamSettingsResultFake { + public function __construct(private array|false $row) { + } + + public function fetchAssociative(): array|false { + return $this->row; + } + } + + final class TeamSettingsExpressionFake { + public function eq(mixed $left, mixed $right): array { + return [$left, $right]; + } + } + + final class TeamSettingsConnectionFake implements IDBConnection { + public array|false $row = false; + public ?int $insertFailureReason = null; + public int $insertAttempts = 0; + public int $updateAttempts = 0; + + public function getQueryBuilder(): TeamSettingsQueryBuilderFake { + return new TeamSettingsQueryBuilderFake($this); + } + } + + final class TeamSettingsQueryBuilderFake { + private string $operation = 'select'; + private array $values = []; + + public function __construct(private TeamSettingsConnectionFake $connection) { + } + + public function select(string ...$columns): self { + $this->operation = 'select'; + return $this; + } + + public function from(string $table): self { + return $this; + } + + public function where(mixed $condition): self { + return $this; + } + + public function expr(): TeamSettingsExpressionFake { + return new TeamSettingsExpressionFake(); + } + + public function createNamedParameter(mixed $value, mixed $type = null): mixed { + return $value; + } + + public function insert(string $table): self { + $this->operation = 'insert'; + return $this; + } + + public function values(array $values): self { + $this->values = $values; + return $this; + } + + public function update(string $table): self { + $this->operation = 'update'; + return $this; + } + + public function set(string $column, mixed $value): self { + $this->values[$column] = $value; + return $this; + } + + public function executeQuery(): TeamSettingsResultFake { + return new TeamSettingsResultFake($this->connection->row); + } + + public function executeStatement(): int { + if ($this->operation === 'insert') { + $this->connection->insertAttempts++; + if ($this->connection->insertFailureReason !== null) { + $reason = $this->connection->insertFailureReason; + if ($reason === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + $this->connection->row = [ + 'team_code' => $this->values['team_code'], + 'display_name' => 'Concurrent name', + 'settings_json' => '{}', + ]; + } + throw new Exception($reason); + } + $this->connection->row = $this->values; + return 1; + } + + $this->connection->updateAttempts++; + if ($this->connection->row !== false) { + $this->connection->row = array_merge($this->connection->row, $this->values); + } + return 1; + } + } + + $connection = new TeamSettingsConnectionFake(); + $connection->insertFailureReason = Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION; + $repository = new TeamSettingsRepository($connection); + $repository->save('A1', 'Latest name', ['meetingDay' => 'monday']); + + assertSameValue(1, $connection->insertAttempts, 'A first save should attempt one insert.'); + assertSameValue(1, $connection->updateAttempts, 'A concurrent first insert must be recovered with one update.'); + assertSameValue('Latest name', $connection->row['display_name'] ?? null, 'The successfully completed save must persist its display name.'); + assertSameValue('{"meetingDay":"monday"}', $connection->row['settings_json'] ?? null, 'The successfully completed save must persist its settings.'); + + $failingConnection = new TeamSettingsConnectionFake(); + $failingConnection->insertFailureReason = Exception::REASON_DRIVER; + $failingRepository = new TeamSettingsRepository($failingConnection); + try { + $failingRepository->save('A1', 'Name', []); + } catch (Exception $exception) { + assertSameValue(Exception::REASON_DRIVER, $exception->getReason(), 'A non-unique database failure must propagate unchanged.'); + assertSameValue(0, $failingConnection->updateAttempts, 'A non-unique database failure must not trigger an update.'); + echo 'AdPlaner team settings repository tests passed' . PHP_EOL; + return; + } + + throw new RuntimeException('A non-unique database failure must not be swallowed.'); +} diff --git a/tests/RouteContractTest.php b/tests/RouteContractTest.php new file mode 100644 index 0000000..017fd64 --- /dev/null +++ b/tests/RouteContractTest.php @@ -0,0 +1,19 @@ +rejectNextTransition) { + $this->rejectNextTransition = false; + return false; + } $key = $teamCode . '|' . $month; if ($this->monthStatus($teamCode, $month) !== $expectedStatus) { return false; @@ -45,6 +43,31 @@ public function transitionMonthStatus(string $teamCode, string $month, string $e return true; } + public function ensureMonthStatus(string $teamCode, string $month, string $updatedByUid): void { + $this->statuses[$teamCode . '|' . $month] ??= 'draft'; + } + + public function lockMonthStatus(string $teamCode, string $month, array $expectedStatuses): ?string { + $status = $this->monthStatus($teamCode, $month); + + return in_array($status, $expectedStatuses, true) ? $status : null; + } + + public function transactional(callable $operation): mixed { + $this->transactionCalls++; + $statuses = $this->statuses; + $updatedSlots = $this->updatedSlots; + $insertedSlots = $this->insertedSlots; + try { + return $operation(); + } catch (\Throwable $exception) { + $this->statuses = $statuses; + $this->updatedSlots = $updatedSlots; + $this->insertedSlots = $insertedSlots; + throw $exception; + } + } + public function slotForMonth(int $slotId, string $teamCode, string $month): ?ShiftSlot { return new ShiftSlot($slotId, $teamCode, $month, $month . '-01', 'early', 'Früh', '08:00', '14:00', true); } @@ -53,7 +76,7 @@ public function slotsForMonth(string $teamCode, string $month): array { return [new ShiftSlot(1, $teamCode, $month, $month . '-01', 'early', 'Früh', '08:00', '14:00', true)]; } - public function candidatesForSlotIds(array $slotIds): array { return []; } + public function candidatesForSlotIds(array $slotIds): array { return $this->candidatesBySlot; } public function dayNotesForMonth(string $teamCode, string $month): array { return []; } public function addCandidate(int $slotId, string $assistantUid, string $createdByUid): void { @@ -74,7 +97,17 @@ public function insertSlot( string $endsAt, bool $enabled ): int { - return 100; + $this->insertedSlots[] = compact( + 'teamCode', + 'month', + 'workDate', + 'segmentKey', + 'label', + 'startsAt', + 'endsAt', + 'enabled' + ); + return 100 + count($this->insertedSlots); } } @@ -115,6 +148,13 @@ public function forMonth(string $month, array $employeeUids): array { return []; 'A draft plan may not skip the planned state.' ); +$directStore = new MonthPlanStatusStoreFake(); +$directService = new ScheduleService($directStore, new ShiftConfigService(), new MonthPlanStatusTeamAccessFake(), new MonthPlanStatusHintServiceFake()); +assertSameValue('planned', $directService->transitionMonthStatus($ebTeam, '2026-08', 'planned', 'test-eb'), 'A month can be planned without loading it first.'); +assertSameValue('approved', $directService->transitionMonthStatus($ebTeam, '2026-08', 'approved', 'test-eb'), 'A month can be approved without loading it first.'); +assertSameValue(2, $directStore->transactionCalls, 'Status transitions run through the store transaction boundary.'); +assertSameValue(30, count($directStore->insertedSlots), 'Approval materializes every missing slot before freezing the plan.'); + assertSameValue('planned', $service->transitionMonthStatus($ebTeam, '2026-08', 'planned', 'test-eb'), 'EB can mark a draft plan as planned.'); assertSameValue('approved', $service->transitionMonthStatus($ebTeam, '2026-08', 'approved', 'test-eb'), 'EB can approve a planned plan.'); assertDomainException( @@ -122,15 +162,51 @@ public function forMonth(string $month, array $employeeUids): array { return []; 'Approved plans reject candidate mutations.' ); assertDomainException( - static fn() => $service->saveDayNote($ebTeam, '2026-08-01', 'Gesperrt', 'test-eb'), + static fn() => $service->saveDayNote($ebTeam, '2026-08', '2026-08-01', 'Gesperrt', 'test-eb'), 'Approved plans reject note mutations.' ); $store->updatedSlots = []; assertSameValue('approved', $service->monthPlan($ebTeam, '2026-08', 'test-eb')['status'] ?? null, 'Approved status remains visible after reload.'); assertSameValue([], $store->updatedSlots, 'Loading an approved plan does not rewrite frozen slot definitions.'); +$changedSettingsTeam = new Team('A1', 'ad-ASN-A1', 'Team A1', $assistants, true, ['shifts' => [[ + 'key' => 'late', + 'label' => 'Spät neu', + 'startsAt' => '14:00', + 'endsAt' => '20:00', + 'enabled' => true, +]]]); +$approvedSnapshot = $service->monthPlan($changedSettingsTeam, '2026-08', 'test-eb'); +assertSameValue( + ['early'], + array_column($approvedSnapshot['segments'] ?? [], 'key'), + 'Approved plans should render the frozen slot segments instead of later team settings.' +); + +$store->candidatesBySlot = [ + 1 => [ + new \OCA\AdPlaner\Model\ShiftCandidate(1, 1, 'assistant-a', 'test-eb'), + new \OCA\AdPlaner\Model\ShiftCandidate(2, 1, 'former-assistant', 'test-eb'), + ], +]; +$privacyMinimizedSnapshot = $service->monthPlan($ebTeam, '2026-08', 'test-eb'); +assertSameValue( + ['assistant-a'], + array_column($privacyMinimizedSnapshot['days'][0]['slots'][0]['candidates'] ?? [], 'uid'), + 'Approved plans should expose only currently assignable team members instead of freezing historical person data.' +); assertSameValue('planned', $service->transitionMonthStatus($ebTeam, '2026-08', 'planned', 'test-eb'), 'EB has an explicit unlock path back to planned.'); +$insertedBeforeConflict = $store->insertedSlots; +$updatedBeforeConflict = $store->updatedSlots; +$store->rejectNextTransition = true; +assertDomainException( + static fn() => $service->transitionMonthStatus($ebTeam, '2026-08', 'approved', 'test-eb'), + 'A concurrent status conflict rejects approval.' +); +assertSameValue('planned', $store->monthStatus('A1', '2026-08'), 'A failed approval keeps the concurrent month status.'); +assertSameValue($insertedBeforeConflict, $store->insertedSlots, 'A failed approval rolls back newly materialized slots.'); +assertSameValue($updatedBeforeConflict, $store->updatedSlots, 'A failed approval rolls back slot-definition updates.'); $service->addCandidate($ebTeam, '2026-08', 1, 'assistant-a', 'test-eb'); assertSameValue(1, count($store->added), 'Unlocked plans accept candidate mutations again.'); assertSameValue('draft', $service->transitionMonthStatus($ebTeam, '2026-08', 'draft', 'test-eb'), 'EB can explicitly reset a planned plan to draft.'); diff --git a/tests/Service/PlanningHintServiceTest.php b/tests/Service/PlanningHintServiceTest.php index 722212f..97830fa 100644 --- a/tests/Service/PlanningHintServiceTest.php +++ b/tests/Service/PlanningHintServiceTest.php @@ -14,14 +14,10 @@ public function dispatchTyped(Event $event): Event; } namespace { - require __DIR__ . '/helpers.php'; - require __DIR__ . '/../../../localbase/lib/Calendar/AbsenceInterval.php'; - require __DIR__ . '/../../../localbase/lib/Calendar/AbsenceQueryEvent.php'; - require __DIR__ . '/../../../localbase/lib/Calendar/ScheduleConflict.php'; - require __DIR__ . '/../../../localbase/lib/Calendar/ScheduleConflictQueryEvent.php'; - require __DIR__ . '/../../lib/Service/PlanningHintService.php'; + require_once dirname(__DIR__) . '/bootstrap.php'; use OCA\AdPlaner\Service\PlanningHintService; + use OCA\AdPlaner\Service\AdPlanerLogger; use OCA\LocalBase\Calendar\AbsenceInterval; use OCA\LocalBase\Calendar\AbsenceQueryEvent; use OCA\LocalBase\Calendar\ScheduleConflict; @@ -32,8 +28,16 @@ public function dispatchTyped(Event $event): Event; final class PlanningHintEventDispatcherFake implements IEventDispatcher { public bool $provideData = true; + public bool $throwAbsence = false; + public bool $throwCalendar = false; public function dispatchTyped(Event $event): Event { + if ($this->throwAbsence && $event instanceof AbsenceQueryEvent) { + throw new \RuntimeException('Absence provider failed'); + } + if ($this->throwCalendar && $event instanceof ScheduleConflictQueryEvent) { + throw new \RuntimeException('Calendar provider failed'); + } if (!$this->provideData) { return $event; } @@ -41,6 +45,7 @@ public function dispatchTyped(Event $event): Event { if ($event instanceof AbsenceQueryEvent) { $event->add(new AbsenceInterval('assistant-a', new \DateTimeImmutable('2026-08-02', $utc), new \DateTimeImmutable('2026-08-04', $utc), 'planned')); $event->add(new AbsenceInterval('assistant-b', new \DateTimeImmutable('2026-08-05', $utc), new \DateTimeImmutable('2026-08-06', $utc), 'approved')); + $event->add(new AbsenceInterval('foreign-person', new \DateTimeImmutable('2026-08-09', $utc), new \DateTimeImmutable('2026-08-10', $utc), 'approved')); } if ($event instanceof ScheduleConflictQueryEvent && $event->employeeUid() === 'assistant-a') { $event->add(new ScheduleConflict('appointment', new \DateTimeImmutable('2026-08-07 10:00:00', $utc), new \DateTimeImmutable('2026-08-07 11:00:00', $utc), 'Vertraulicher Titel')); @@ -50,8 +55,19 @@ public function dispatchTyped(Event $event): Event { } } + final class PlanningHintLoggerFake extends AdPlanerLogger { + public array $errors = []; + + public function __construct() {} + + public function error(string $action, \Throwable $exception, array $context = []): void { + $this->errors[] = compact('action', 'context'); + } + } + $events = new PlanningHintEventDispatcherFake(); - $service = new PlanningHintService($events); + $logger = new PlanningHintLoggerFake(); + $service = new PlanningHintService($events, $logger); $hints = $service->forMonth('2026-08', ['assistant-a', 'assistant-b']); assertSameValue('U?', $hints['2026-08-02'][0]['marker'] ?? null, 'Planned vacation is exposed as U?.'); @@ -60,6 +76,29 @@ public function dispatchTyped(Event $event): Event { assertSameValue('K', $hints['2026-08-07'][0]['marker'] ?? null, 'Calendar occupation is exposed as a compact marker.'); assertSameValue('Termin', $hints['2026-08-07'][0]['label'] ?? null, 'Calendar titles are not leaked into the team plan.'); assertSameValue('assistant-a', $hints['2026-08-07'][0]['employeeUid'] ?? null, 'Hints remain assigned to the visible team member.'); + assertSameValue(false, isset($hints['2026-08-09']), 'Absences returned for a non-requested person must not enter the team plan.'); + $hintUids = []; + foreach ($hints as $dayHints) { + array_push($hintUids, ...array_column($dayHints, 'employeeUid')); + } + assertSameValue(false, in_array('foreign-person', $hintUids, true), 'A foreign provider UID must not leak through any day hint.'); + + $events->throwAbsence = true; + $withoutAbsenceProvider = $service->forMonth('2026-08', ['assistant-a']); + assertSameValue('K', $withoutAbsenceProvider['2026-08-07'][0]['marker'] ?? null, 'A failing absence provider must not block calendar hints.'); + $events->throwAbsence = false; + $events->throwCalendar = true; + $withoutCalendarProvider = $service->forMonth('2026-08', ['assistant-a']); + assertSameValue('U?', $withoutCalendarProvider['2026-08-02'][0]['marker'] ?? null, 'A failing calendar provider must not block absence hints.'); + assertSameValue( + [ + ['action' => 'planning_hint_absences', 'context' => ['month' => '2026-08']], + ['action' => 'planning_hint_calendar', 'context' => ['month' => '2026-08']], + ], + $logger->errors, + 'Provider failures should be logged without employee identifiers.' + ); + $events->throwCalendar = false; $events->provideData = false; assertSameValue([], $service->forMonth('2026-08', ['assistant-a']), 'Missing providers are a valid empty standalone state.'); diff --git a/tests/Service/ScheduleServiceSmokeTest.php b/tests/Service/ScheduleServiceSmokeTest.php index cbaa304..b132bdf 100644 --- a/tests/Service/ScheduleServiceSmokeTest.php +++ b/tests/Service/ScheduleServiceSmokeTest.php @@ -2,20 +2,7 @@ declare(strict_types=1); -require __DIR__ . '/helpers.php'; -require __DIR__ . '/../../../localbase/lib/Model/ModelApiTrait.php'; -require __DIR__ . '/../../../localbase/lib/Organization/AdOrganizationDefinition.php'; -require __DIR__ . '/../../lib/Model/Assistant.php'; -require __DIR__ . '/../../lib/Model/ShiftCandidate.php'; -require __DIR__ . '/../../lib/Model/ShiftDefinition.php'; -require __DIR__ . '/../../lib/Model/ShiftSlot.php'; -require __DIR__ . '/../../lib/Model/Team.php'; -require __DIR__ . '/../../lib/Repository/ShiftPlanRepository.php'; -require __DIR__ . '/../../lib/Service/ShiftConfigService.php'; -require __DIR__ . '/../../lib/Service/TeamAccessService.php'; -require __DIR__ . '/../../lib/Service/PlanningHintService.php'; -require __DIR__ . '/../../lib/Service/ScheduleService.php'; -require __DIR__ . '/../../lib/Store/ShiftPlanStore.php'; +require_once dirname(__DIR__) . '/bootstrap.php'; use OCA\AdPlaner\Model\ShiftCandidate; use OCA\AdPlaner\Model\ShiftSlot; @@ -30,25 +17,62 @@ class FakeShiftPlanStoreForSchedule extends ShiftPlanStore { public array $added = []; + public array $removed = []; public array $slots = []; public array $updatedSlots = []; public array $insertedSlots = []; + public array $savedNotes = []; + public array $deletedNotes = []; + public bool $slotEnabled = true; + public string $status = 'draft'; + public ?string $statusOnNextLock = null; + public int $transactionCalls = 0; + public array $lockCalls = []; public function __construct() { } public function monthStatus(string $teamCode, string $month): string { - return 'draft'; + return $this->status; + } + + public function transactional(callable $operation): mixed { + $this->transactionCalls++; + return $operation(); + } + + public function ensureMonthStatus(string $teamCode, string $month, string $updatedByUid): void {} + + public function lockMonthStatus(string $teamCode, string $month, array $expectedStatuses): ?string { + $this->lockCalls[] = compact('teamCode', 'month', 'expectedStatuses'); + if ($this->statusOnNextLock !== null) { + $this->status = $this->statusOnNextLock; + $this->statusOnNextLock = null; + } + + return in_array($this->status, $expectedStatuses, true) ? $this->status : null; } public function slotForMonth(int $slotId, string $teamCode, string $month): ?ShiftSlot { - return new ShiftSlot($slotId, $teamCode, $month, $month . '-01', 'early', 'Früh', '08:00', '14:00', true); + return new ShiftSlot($slotId, $teamCode, $month, $month . '-01', 'early', 'Früh', '08:00', '14:00', $this->slotEnabled); } public function addCandidate(int $slotId, string $assistantUid, string $createdByUid): void { $this->added[] = compact('slotId', 'assistantUid', 'createdByUid'); } + public function removeCandidate(int $slotId, string $assistantUid): void { + $this->removed[] = compact('slotId', 'assistantUid'); + } + + public function saveDayNote(string $teamCode, string $workDate, string $note, string $updatedByUid): void { + $this->savedNotes[] = compact('teamCode', 'workDate', 'note', 'updatedByUid'); + } + + public function deleteDayNote(string $teamCode, string $workDate): void { + $this->deletedNotes[] = compact('teamCode', 'workDate'); + } + public function slotsForMonth(string $teamCode, string $month): array { if ($this->slots !== []) { return $this->slots; @@ -160,12 +184,34 @@ public function forMonth(string $month, array $employeeUids): array { $store = new FakeShiftPlanStoreForSchedule(); $service = new ScheduleService($store, new ShiftConfigService(), new FakeTeamAccessServiceForSchedule(), new FakePlanningHintServiceForSchedule()); +$approvalRaceStore = new FakeShiftPlanStoreForSchedule(); +$approvalRaceStore->statusOnNextLock = 'approved'; +$approvalRaceService = new ScheduleService($approvalRaceStore, new ShiftConfigService(), new FakeTeamAccessServiceForSchedule(), new FakePlanningHintServiceForSchedule()); +$approvalRacePlan = $approvalRaceService->monthPlan($ebTeam, '2026-07', 'test-eb'); +assertSameValue('approved', $approvalRacePlan['status'] ?? null, 'A concurrent approval must win over mutable month materialization.'); +assertSameValue([], $approvalRaceStore->updatedSlots, 'A concurrent approval must prevent slot-definition rewrites.'); +assertSameValue([], $approvalRaceStore->insertedSlots, 'A concurrent approval must prevent missing-slot inserts.'); +assertSameValue(1, $approvalRaceStore->transactionCalls, 'Mutable month materialization must run inside one transaction.'); + $service->addCandidate($assistantTeam, '2026-07', 9, '', 'assistant-a'); assertSameValue('assistant-a', $store->added[0]['assistantUid'] ?? null, 'Assistant should be able to add themself.'); $service->addCandidate($ebTeam, '2026-07', 9, 'assistant-a', 'test-eb'); assertSameValue('assistant-a', $store->added[1]['assistantUid'] ?? null, 'EB should be able to assign an assistant.'); +$service->removeCandidate($assistantTeam, '2026-07', 9, '', 'assistant-a'); +assertSameValue( + ['slotId' => 9, 'assistantUid' => 'assistant-a'], + $store->removed[0] ?? null, + 'An assistant should be able to remove their own request.' +); +$service->removeCandidate($ebTeam, '2026-07', 9, 'assistant-a', 'test-eb'); +assertSameValue( + ['slotId' => 9, 'assistantUid' => 'assistant-a'], + $store->removed[1] ?? null, + 'The responsible EB should be able to remove another assistant assignment.' +); + assertDomainException( static fn() => $service->addCandidate($ebTeam, '2026-07', 9, '', 'test-eb'), 'EB should not be able to add themself without selecting an assistant.' @@ -178,11 +224,96 @@ public function forMonth(string $month, array $employeeUids): array { static fn() => $service->addCandidate($assistantTeam, '2026-07', 9, 'test-eb', 'assistant-a'), 'Assistants should not assign other users.' ); +assertDomainException( + static fn() => $service->removeCandidate($assistantTeam, '2026-07', 9, 'test-eb', 'assistant-a'), + 'Assistants should not remove other users.' +); +assertDomainException( + static fn() => $service->saveDayNote($assistantTeam, '2026-07', '2026-07-01', 'Nicht erlaubt', 'assistant-a'), + 'Assistants should not edit day notes.' +); +$addedBeforeDisabledMutation = count($store->added); +$store->slotEnabled = false; +assertDomainException( + static fn() => $service->addCandidate($ebTeam, '2026-07', 9, 'assistant-a', 'test-eb'), + 'Disabled shift slots must reject direct candidate mutations.' +); +assertSameValue($addedBeforeDisabledMutation, count($store->added), 'A rejected disabled-slot mutation must not persist a candidate.'); +$store->slotEnabled = true; + +$store->status = 'planned'; +$store->statusOnNextLock = 'approved'; +$addedBeforeConcurrentApproval = count($store->added); +assertDomainException( + static fn() => $service->addCandidate($ebTeam, '2026-07', 9, 'assistant-a', 'test-eb'), + 'A candidate mutation racing with approval must fail closed.' +); +assertSameValue($addedBeforeConcurrentApproval, count($store->added), 'Concurrent approval must prevent the candidate write.'); + +$store->status = 'planned'; +$store->statusOnNextLock = 'approved'; +assertDomainException( + static fn() => $service->saveDayNote($ebTeam, '2026-07', '2026-07-01', 'Nicht speichern', 'test-eb'), + 'A note mutation racing with approval must fail closed.' +); +assertSameValue([], $store->savedNotes, 'Concurrent approval must prevent the note write.'); +$store->status = 'draft'; + +$mismatchedMonthMessage = ''; +try { + $service->saveDayNote($ebTeam, '2026-07', '2026-08-01', 'Nicht speichern', 'test-eb'); +} catch (\Throwable $error) { + $mismatchedMonthMessage = $error->getMessage(); +} +assertSameValue( + 'Das Datum gehört nicht zum ausgewählten Planungsmonat.', + $mismatchedMonthMessage, + 'A day note should reject a work date outside the requested plan month.' +); +assertSameValue([], $store->savedNotes, 'A rejected cross-month day note must not be persisted.'); +$service->saveDayNote($ebTeam, '2026-07', '2026-07-01', ' Hinweis ', 'test-eb'); +assertSameValue( + [[ + 'teamCode' => 'A1', + 'workDate' => '2026-07-01', + 'note' => 'Hinweis', + 'updatedByUid' => 'test-eb', + ]], + $store->savedNotes, + 'A day note inside the requested month should be trimmed and persisted.' +); +$service->saveDayNote($ebTeam, '2026-07', '2026-07-01', " \n\t ", 'test-eb'); +assertSameValue( + [['teamCode' => 'A1', 'workDate' => '2026-07-01']], + $store->deletedNotes, + 'Clearing a day note should remove its no-longer-needed row and editor metadata.' +); +assertSameValue(1, count($store->savedNotes), 'Clearing a day note must not persist an empty replacement row.'); + +$maximumNote = str_repeat('Ä', 2000); +$service->saveDayNote($ebTeam, '2026-07', '2026-07-01', $maximumNote, 'test-eb'); +assertSameValue($maximumNote, $store->savedNotes[array_key_last($store->savedNotes)]['note'] ?? null, 'A 2,000-character Unicode day note should be valid.'); + +$assertInvalidNoteWithoutWrite = static function (string $note, string $message) use ($service, $ebTeam, $store): void { + $before = count($store->savedNotes); + try { + $service->saveDayNote($ebTeam, '2026-07', '2026-07-01', $note, 'test-eb'); + } catch (\InvalidArgumentException) { + assertSameValue($before, count($store->savedNotes), $message); + return; + } + + throw new \RuntimeException($message); +}; +$assertInvalidNoteWithoutWrite(str_repeat('Ä', 2001), 'A day note longer than 2,000 Unicode characters must be rejected without persistence.'); +$assertInvalidNoteWithoutWrite("\xC3\x28", 'An invalid UTF-8 day note must be rejected without persistence.'); $plan = $service->monthPlan($ebTeam, '2026-07', 'test-eb'); $slotCandidates = $plan['days'][0]['slots'][0]['candidates'] ?? []; assertSameValue(['assistant-a'], array_column($slotCandidates, 'uid'), 'Month plan should hide non-assignable EB candidates.'); +assertSameValue(false, array_key_exists('createdByUid', $slotCandidates[0] ?? []), 'Month plans must not expose the internal candidate creator uid.'); assertSameValue('Assistant A', $plan['days'][0]['hints'][0]['displayName'] ?? null, 'Planning hints use the visible team label without exposing foreign details.'); +assertSameValue(false, array_key_exists('employeeUid', $plan['days'][0]['hints'][0] ?? []), 'Public planning hints must not expose an internal Nextcloud uid once the visible label is resolved.'); $configuredStore = new FakeShiftPlanStoreForSchedule(); $configuredStore->slots = [ diff --git a/tests/Service/ShiftConfigServiceSmokeTest.php b/tests/Service/ShiftConfigServiceSmokeTest.php index b0047f0..1543f8d 100644 --- a/tests/Service/ShiftConfigServiceSmokeTest.php +++ b/tests/Service/ShiftConfigServiceSmokeTest.php @@ -2,10 +2,7 @@ declare(strict_types=1); -require __DIR__ . '/helpers.php'; -require __DIR__ . '/../../../localbase/lib/Model/ModelApiTrait.php'; -require __DIR__ . '/../../lib/Model/ShiftDefinition.php'; -require __DIR__ . '/../../lib/Service/ShiftConfigService.php'; +require_once dirname(__DIR__) . '/bootstrap.php'; use OCA\AdPlaner\Service\ShiftConfigService; use function OCA\AdPlaner\Tests\assertSameValue; @@ -50,6 +47,7 @@ ]); $customSegments = $service->segments($customSettings); $days = $service->monthDays('2026-02'); +$leapDays = $service->monthDays('2028-02'); assertSameValue('2026-07-15', $customSettings['meetingDay'], 'Meeting day should be preserved.'); assertSameValue(['first', 'overlap', 'night'], array_column($customSegments, 'key'), 'Custom shifts should keep their configured order.'); @@ -60,6 +58,27 @@ assertSameValue(false, $customSegments[2]['enabled'], 'Disabled custom shifts should be preserved.'); assertSameValue(28, count($days), 'February 2026 should have 28 days.'); assertSameValue('2026-02-01', $days[0]['date'], 'First month day should be correct.'); +assertSameValue(29, count($leapDays), 'A leap-year February should have 29 days.'); +assertSameValue('2028-02-29', $leapDays[28]['date'] ?? null, 'The leap day should occur exactly once at the end of February 2028.'); + +$gappedSegments = $service->segments($service->normalize(['shifts' => [ + ['key' => 'morning', 'label' => 'Vormittag', 'startsAt' => '08:00', 'endsAt' => '10:00', 'enabled' => true], + ['key' => 'afternoon', 'label' => 'Nachmittag', 'startsAt' => '12:00', 'endsAt' => '14:00', 'enabled' => true], +]])); +assertSameValue( + [['08:00', '10:00'], ['12:00', '14:00']], + array_map(static fn(array $shift): array => [$shift['startsAt'], $shift['endsAt']], $gappedSegments), + 'A deliberate gap between shifts should remain valid and must not be closed automatically.' +); + +$unicodeBoundary = $service->normalize(['shifts' => [[ + 'key' => 'unicode', + 'label' => str_repeat('Ä', 64), + 'startsAt' => '08:00', + 'endsAt' => '09:00', + 'enabled' => true, +]]]); +assertSameValue(str_repeat('Ä', 64), $unicodeBoundary['shifts'][0]['label'], 'A 64-character Unicode shift label should be valid.'); $assertInvalidArgument = static function (callable $operation, string $message): void { try { @@ -74,6 +93,18 @@ static fn() => $service->normalize(['shifts' => 'invalid']), 'Non-list shift settings should be rejected.' ); +$assertInvalidArgument( + static fn() => $service->normalize(['shifts' => [ + 'named-shift' => [ + 'key' => 'day', + 'label' => 'Tag', + 'startsAt' => '08:00', + 'endsAt' => '16:00', + 'enabled' => true, + ], + ]]), + 'JSON objects must not be silently reinterpreted as ordered shift lists.' +); $assertInvalidArgument( static fn() => $service->monthDays('2026-13'), 'Out-of-range months should be rejected.' @@ -86,5 +117,55 @@ static fn() => $service->normalizeDate('30.02.2026'), 'Non-ISO calendar dates should be rejected.' ); +$assertInvalidArgument( + static fn() => $service->normalize(['shifts' => [[ + 'key' => 'blank-label', + 'label' => ' ', + 'startsAt' => '08:00', + 'endsAt' => '09:00', + 'enabled' => true, + ]]]), + 'A blank shift label must be rejected instead of silently replaced.' +); +$assertInvalidArgument( + static fn() => $service->normalize(['shifts' => [[ + 'key' => 'too-long', + 'label' => str_repeat('Ä', 65), + 'startsAt' => '08:00', + 'endsAt' => '09:00', + 'enabled' => true, + ]]]), + 'A Unicode shift label longer than 64 characters should be rejected.' +); +$assertInvalidArgument( + static fn() => $service->normalize(['shifts' => [[ + 'key' => 'invalid-utf8', + 'label' => "\xC3\x28", + 'startsAt' => '08:00', + 'endsAt' => '09:00', + 'enabled' => true, + ]]]), + 'An invalid UTF-8 shift label should be rejected.' +); +$invalidEnabledShift = static fn(mixed $enabled): array => ['shifts' => [[ + 'key' => 'typed-enabled', + 'label' => 'Typisiert', + 'startsAt' => '08:00', + 'endsAt' => '09:00', + 'enabled' => $enabled, +]]]; +$assertInvalidArgument( + static fn() => $service->normalize($invalidEnabledShift('irgendwie')), + 'Unknown enabled strings must not silently disable a shift.' +); +$assertInvalidArgument( + static fn() => $service->normalize($invalidEnabledShift([])), + 'Structured enabled values must be rejected instead of coerced.' +); +$assertInvalidArgument( + static fn() => $service->normalize($invalidEnabledShift(2)), + 'Enabled integers other than zero and one must be rejected.' +); +assertSameValue(false, $service->normalize($invalidEnabledShift('false'))['shifts'][0]['enabled'], 'The explicit false string remains supported.'); echo 'AdPlaner shift config smoke tests passed' . PHP_EOL; diff --git a/tests/Service/TeamAccessServiceSmokeTest.php b/tests/Service/TeamAccessServiceSmokeTest.php index a89dc8d..b0b09c9 100644 --- a/tests/Service/TeamAccessServiceSmokeTest.php +++ b/tests/Service/TeamAccessServiceSmokeTest.php @@ -10,14 +10,7 @@ eval('namespace OCP; interface IUserSession { public function getUser(); }'); } - require __DIR__ . '/helpers.php'; - require __DIR__ . '/../../../localbase/lib/Model/ModelApiTrait.php'; - require __DIR__ . '/../../../localbase/lib/Organization/AdOrganizationDefinition.php'; - require __DIR__ . '/../../lib/Model/Assistant.php'; - require __DIR__ . '/../../lib/Model/Team.php'; - require __DIR__ . '/../../lib/Model/TeamSettings.php'; - require __DIR__ . '/../../lib/Service/TeamSettingsService.php'; - require __DIR__ . '/../../lib/Service/TeamAccessService.php'; + require_once dirname(__DIR__) . '/bootstrap.php'; use OCA\AdPlaner\Model\TeamSettings; use OCA\AdPlaner\Service\TeamAccessService; @@ -46,6 +39,10 @@ public function getDisplayName(): string { public function getEMailAddress(): string { return $this->email; } + + public function isEnabled(): bool { + return true; + } }; $bob = new class('bob', 'Bob EB', 'bob@example.invalid') { public function __construct( @@ -66,6 +63,15 @@ public function getDisplayName(): string { public function getEMailAddress(): string { return $this->email; } + + public function isEnabled(): bool { + return true; + } + }; + $disabledAssistant = new class { + public function getUID(): string { return 'disabled-assistant'; } + public function getDisplayName(): string { return 'Disabled Assistant'; } + public function isEnabled(): bool { return false; } }; $legacyEb = new class('legacy-eb', 'Legacy EB', '') { public function __construct(private string $uid, private string $displayName, private string $email) {} @@ -73,12 +79,24 @@ public function getUID(): string { return $this->uid; } public function getDisplayName(): string { return $this->displayName; } public function getEMailAddress(): string { return $this->email; } }; + $zeroNameUser = new class { + public function getUID(): string { return 'zero-name'; } + public function getDisplayName(): string { return '0'; } + }; + assertSameValue( + '0', + \OCA\AdPlaner\Model\Assistant::fromUser($zeroNameUser, false)->displayName, + 'The valid Nextcloud display name "0" must not be replaced with the uid.' + ); + + $teamGroup = new class([$alice, $bob, $disabledAssistant]) { + public int $getUsersCalls = 0; - $teamGroup = new class([$alice, $bob]) { public function __construct(private array $users) { } public function getUsers(): array { + $this->getUsersCalls++; return $this->users; } }; @@ -126,10 +144,13 @@ public function setUser(?object $user): void { }; $settings = new class extends TeamSettingsService { + public int $settingsCalls = 0; + public function __construct() { } public function settingsForTeam(string $teamCode): TeamSettings { + $this->settingsCalls++; return new TeamSettings($teamCode, $teamCode === 'TeamB' ? 'Team B' : $teamCode, ['meetingDay' => '2026-07-15']); } }; @@ -140,11 +161,17 @@ public function settingsForTeam(string $teamCode): TeamSettings { assertSameValue('TeamB', $service->normalizeTeamCode(' TeamB '), 'Team codes should be trimmed.'); assertSameValue(true, $service->currentUserIsEbForTeam('TeamB'), 'EB users should be detected through ad-EB groups.'); - $team = $service->teamForCode('TeamB'); + $team = $service->assertTeamAccess('TeamB'); assertSameValue('Team B', $team->displayName, 'Team display name should come from team settings.'); assertSameValue('ad-ASN-TeamB', $team->groupName, 'Team group name should follow the AD schema.'); assertSameValue(['Alice Assistenz', 'Bob EB'], array_map(static fn($assistant): string => $assistant->displayName, $team->assistants()), 'Assistants should be sorted by display name.'); + assertSameValue(null, $team->assistantByUid('disabled-assistant'), 'Disabled Nextcloud users must not be exposed as current shift-capable team members.'); assertSameValue(false, $team->assistantByUid('bob')->canReceiveShifts, 'EB users should not receive shifts.'); + assertSameValue( + ['alice'], + array_column($team->toArray()['assistants'] ?? [], 'uid'), + 'Public team payloads should contain only current shift-capable assistants.' + ); assertSameValue(['alice' => 'Alice Assistenz', 'bob' => 'Bob EB'], $service->assistantLabelMap($team->assistants()), 'Assistant label maps should expose display names by uid.'); assertSameValue( ['carla' => 'Carla Assistenz'], @@ -188,10 +215,15 @@ public function settingsForTeam(string $teamCode): TeamSettings { $session->setUser(null); assertSameValue([], $service->teamsForCurrentUser(), 'Anonymous sessions should not expose teams.'); assertSameValue(false, $service->currentUserIsEbForTeam('TeamB'), 'Anonymous sessions should not receive EB rights.'); + $settings->settingsCalls = 0; + $teamGroup->getUsersCalls = 0; assertDomainException( static fn() => $service->assertTeamAccess('TeamB'), 'Anonymous sessions should not access an existing team.' ); + assertSameValue(0, $settings->settingsCalls, 'Denied team access must not load protected team settings.'); + assertSameValue(0, $teamGroup->getUsersCalls, 'Denied team access must not enumerate protected team members.'); + assertSameValue(true, (new \ReflectionMethod(TeamAccessService::class, 'teamForCode'))->isPrivate(), 'The unguarded team loader must not remain a public service path.'); try { $service->currentUserId(); throw new RuntimeException('Anonymous sessions received a user id.'); diff --git a/tests/Service/TeamSettingsStoreTest.php b/tests/Service/TeamSettingsStoreTest.php new file mode 100644 index 0000000..a02ca66 --- /dev/null +++ b/tests/Service/TeamSettingsStoreTest.php @@ -0,0 +1,64 @@ +row; + } + + public function save(string $teamCode, string $displayName, array $settings): void { + $this->saved[] = compact('teamCode', 'displayName', 'settings'); + } +} + +$repository = new TeamSettingsRepositoryFake(); +$store = new TeamSettingsStore($repository, new ShiftConfigService()); +$config = ['shifts' => [[ + 'key' => 'day', + 'label' => 'Tag', + 'startsAt' => '08:00', + 'endsAt' => '16:00', + 'enabled' => true, +]]]; + +$validName = str_repeat('Ä', 255); +$saved = $store->save('A1', $validName, $config); +assertSameValue($validName, $saved->displayName, 'A 255-character Unicode team display name should be valid.'); +assertSameValue(1, count($repository->saved), 'A valid team display name should be persisted once.'); + +$assertRejectedWithoutWrite = static function (string $displayName, string $message) use ($store, $repository, $config): void { + $before = count($repository->saved); + try { + $store->save('A1', $displayName, $config); + } catch (\InvalidArgumentException) { + assertSameValue($before, count($repository->saved), $message); + return; + } + + throw new \RuntimeException($message); +}; + +$assertRejectedWithoutWrite(str_repeat('Ä', 256), 'A team display name longer than 255 characters must be rejected without persistence.'); +$assertRejectedWithoutWrite("\xC3\x28", 'An invalid UTF-8 team display name must be rejected without persistence.'); +$assertRejectedWithoutWrite(' ', 'A blank team display name must be rejected without persistence.'); + +$repository->row = [ + 'display_name' => '0', + 'settings_json' => json_encode($config, JSON_THROW_ON_ERROR), +]; +assertSameValue('0', $store->forTeam('A1')->displayName, 'The valid display name "0" must survive the persistence roundtrip.'); + +echo 'AdPlaner team settings store tests passed' . PHP_EOL; diff --git a/tests/Service/helpers.php b/tests/Service/helpers.php index 774791e..ed7a31b 100644 --- a/tests/Service/helpers.php +++ b/tests/Service/helpers.php @@ -4,8 +4,6 @@ namespace OCA\AdPlaner\Tests; -require_once __DIR__ . '/../../../localbase/tests/Support/assertions.php'; - use function OCA\LocalBase\Tests\Support\assertSameValue as supportAssertSameValue; use function OCA\LocalBase\Tests\Support\assertThrows as supportAssertThrows; diff --git a/tests/StandaloneNavigationListenerTest.php b/tests/StandaloneNavigationListenerTest.php index fdf3308..7123fed 100644 --- a/tests/StandaloneNavigationListenerTest.php +++ b/tests/StandaloneNavigationListenerTest.php @@ -8,7 +8,7 @@ namespace OCP\App { interface IAppManager { public function isEnabledForUser($appId, $user = null); } } namespace { - require_once __DIR__ . '/../../localbase/lib/Service/StandaloneAppNavigationService.php'; require_once __DIR__ . '/../lib/Listener/StandaloneNavigationListener.php'; + require_once __DIR__ . '/bootstrap.php'; use OCA\AdPlaner\Listener\StandaloneNavigationListener; use OCA\LocalBase\Service\StandaloneAppNavigationService; use OCP\App\IAppManager; use OCP\INavigationManager; use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserSession; use OCP\Navigation\Events\LoadAdditionalEntriesEvent; $user = new class implements IUser {}; $session = new class($user) implements IUserSession { public function __construct(private IUser $user) {} public function getUser(): ?IUser { return $this->user; } }; $apps = new class implements IAppManager { public function isEnabledForUser($appId, $user = null): bool { return false; } }; $nav = new class implements INavigationManager { public array $entries = []; public function add(callable $entry): void { $this->entries[] = $entry; } }; $url = new class implements IURLGenerator { public function linkToRoute(string $routeName, array $arguments = []): string { return $routeName; } public function imagePath(string $appName, string $file): string { return "$appName/$file"; } }; (new StandaloneNavigationListener(new StandaloneAppNavigationService($session, $apps, $nav, $url)))->handle(new LoadAdditionalEntriesEvent()); $entry = ($nav->entries[0] ?? static fn(): array => [])(); diff --git a/tests/Ui/LayoutSmokeTest.php b/tests/Ui/LayoutSmokeTest.php index d350c50..06459fe 100644 --- a/tests/Ui/LayoutSmokeTest.php +++ b/tests/Ui/LayoutSmokeTest.php @@ -13,7 +13,12 @@ throw new RuntimeException("Der sichtbare Monatsplan-Scrollvertrag fehlt: {$contract}"); } } -foreach (['id="month-prev"', 'id="month-next"', 'aria-label="Vorheriger Monat"', 'aria-label="Nächster Monat"'] as $contract) { +foreach (['.adp-assignment-control', 'position: relative', '.adp-assignment-picker[hidden]', 'display: none', 'position: absolute'] as $contract) { + if (!str_contains($css, $contract)) { + throw new RuntimeException("Der kompakte Zuteilungsdialog fehlt: {$contract}"); + } +} +foreach (['id="month-prev"', 'id="month-next"', 'aria-label="Vorheriger Monat"', 'aria-label="Nächster Monat"', 'min="2000-01"', 'max="2100-12"', 'required'] as $contract) { if (!str_contains($template, $contract)) { throw new RuntimeException("Die direkte Monatsnavigation fehlt: {$contract}"); } diff --git a/tests/access-http-smoke.sh b/tests/access-http-smoke.sh index 54402f7..7b05a57 100755 --- a/tests/access-http-smoke.sh +++ b/tests/access-http-smoke.sh @@ -60,17 +60,28 @@ curl --fail --silent --show-error --insecure --user "$ADP_USER:$ADP_PASSWORD" \ --cookie "$cookies" --cookie-jar "$cookies" -H "requesttoken: $token" \ "$plan_endpoint" --output "$plan_after" php -r ' -$data = json_decode(file_get_contents($argv[1]), true, flags: JSON_THROW_ON_ERROR); -$foreignUid = $argv[2]; -foreach (($data["days"] ?? []) as $day) { - foreach (($day["slots"] ?? []) as $slot) { - foreach (($slot["candidates"] ?? []) as $candidate) { - if (($candidate["assistantUid"] ?? "") === $foreignUid) { - throw new RuntimeException("Die abgewiesene Fremdänderung wurde dennoch gespeichert."); +$before = json_decode(file_get_contents($argv[1]), true, flags: JSON_THROW_ON_ERROR); +$after = json_decode(file_get_contents($argv[2]), true, flags: JSON_THROW_ON_ERROR); +$slotId = (int)$argv[3]; +$candidateUids = static function (array $plan, int $slotId): array { + foreach (($plan["days"] ?? []) as $day) { + foreach (($day["slots"] ?? []) as $slot) { + if ((int)($slot["id"] ?? 0) !== $slotId) { + continue; } + $uids = array_map(static fn(array $candidate): string => (string)($candidate["uid"] ?? ""), $slot["candidates"] ?? []); + sort($uids); + return $uids; } } + + throw new RuntimeException("Die geprüfte Schicht fehlt im Monatsplan."); +}; +$beforeCandidateUids = $candidateUids($before, $slotId); +$afterCandidateUids = $candidateUids($after, $slotId); +if ($beforeCandidateUids !== $afterCandidateUids) { + throw new RuntimeException("Die abgewiesene Fremdänderung hat den Kandidatenzustand verändert."); } -' "$plan_after" "$ADP_FOREIGN_UID" +' "$plan" "$plan_after" "$slot_id" echo "AdPlaner C3 API access smoke: OK ($ADP_USER)" diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..59ff676 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,29 @@ + $workspaceRoot . '/localbase/tests/Support/', + 'OCA\\LocalBase\\' => $workspaceRoot . '/localbase/lib/', + 'OCA\\AdPlaner\\' => $appRoot . '/lib/', + ]; + + foreach ($prefixes as $prefix => $directory) { + if (!str_starts_with($class, $prefix)) { + continue; + } + + $file = $directory . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; + if (is_file($file)) { + require_once $file; + } + return; + } +}); + +require_once $workspaceRoot . '/localbase/tests/Support/assertions.php'; +require_once __DIR__ . '/Service/helpers.php'; diff --git a/tests/browser-ddev-smoke.sh b/tests/browser-ddev-smoke.sh new file mode 100755 index 0000000..34e21e1 --- /dev/null +++ b/tests/browser-ddev-smoke.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_url="${ADP_BASE_URL:-https://nextcloud-dev.ddev.site}" +ddev_project="${ADP_DDEV_PROJECT:-$(cd "$(dirname "$0")/../../nextcloud-dev" && pwd)}" +suffix="$(date +%s)$$" +team_code="P${suffix: -15}" +team_group="ad-ASN-$team_code" +month='2098-11' +password="$(php -r 'echo bin2hex(random_bytes(24));')" +actor="adp-browser-$suffix-eb" +assistant="adp-browser-$suffix-assistant" +member="adp-browser-$suffix-member" +created_users=() +workdir="$(mktemp -d)" +chrome_profile="$workdir/chrome-profile" +chrome_log="$workdir/chrome.log" +screenshot="$workdir/adplaner-approved.png" +chrome_pid='' +probe='/var/www/html/html/custom_apps/adplaner/tests/integration/PrivacyRuntimeProbe.php' + +occ() { + (cd "$ddev_project" && ddev exec -d /var/www/html/html php occ "$@") +} + +run_probe() { + (cd "$ddev_project" && ddev exec -d /var/www/html/html php "$probe" "$@") +} + +cleanup() { + local failed=0 + if [[ -n "$chrome_pid" ]] && kill -0 "$chrome_pid" >/dev/null 2>&1; then + kill "$chrome_pid" >/dev/null 2>&1 || failed=1 + wait "$chrome_pid" >/dev/null 2>&1 || true + fi + run_probe cleanup "$team_code" "$month" >/dev/null || failed=1 + local uid + for uid in "${created_users[@]}"; do + occ user:delete "$uid" >/dev/null 2>&1 || failed=1 + done + occ group:delete "$team_group" >/dev/null 2>&1 || failed=1 + rm -rf "$workdir" + return "$failed" +} + +report_failed_cleanup() { + echo 'Der Browser-Smoke wurde abgebrochen; die automatische Bereinigung war nicht vollständig erfolgreich.' >&2 + echo "Bitte synthetische Objekte prüfen: Team $team_code, Gruppe $team_group, Nutzer ${created_users[*]:-keine}." >&2 +} + +trap 'cleanup || report_failed_cleanup' EXIT + +create_user() { + local uid="$1" + (cd "$ddev_project" && ddev exec -d /var/www/html/html env OC_PASS="$password" php occ user:add --password-from-env "$uid") >/dev/null + created_users+=("$uid") + occ group:adduser "$team_group" "$uid" >/dev/null +} + +if ! occ group:info ad-EB >/dev/null 2>&1; then + echo 'Die bestehende EB-Rollengruppe ad-EB fehlt; der Smoke verändert die Organisationskonfiguration nicht.' >&2 + exit 1 +fi + +occ group:add "$team_group" >/dev/null +create_user "$actor" +create_user "$assistant" +create_user "$member" +occ group:adduser ad-EB "$actor" >/dev/null + +mkdir -p "$chrome_profile" +google-chrome \ + --headless=new \ + --disable-dev-shm-usage \ + --ignore-certificate-errors \ + --remote-allow-origins='*' \ + --remote-debugging-port=0 \ + --user-data-dir="$chrome_profile" \ + about:blank >"$chrome_log" 2>&1 & +chrome_pid="$!" + +for _ in $(seq 1 100); do + [[ -s "$chrome_profile/DevToolsActivePort" ]] && break + if ! kill -0 "$chrome_pid" >/dev/null 2>&1; then + echo 'Headless Chrome wurde unerwartet beendet.' >&2 + sed -n '1,120p' "$chrome_log" >&2 + exit 1 + fi + sleep 0.1 +done +if [[ ! -s "$chrome_profile/DevToolsActivePort" ]]; then + echo 'Chrome stellte innerhalb des Zeitlimits keinen DevTools-Port bereit.' >&2 + exit 1 +fi +cdp_port="$(sed -n '1p' "$chrome_profile/DevToolsActivePort")" + +ADP_CDP_PORT="$cdp_port" \ +ADP_BASE_URL="$base_url" \ +ADP_BROWSER_EB="$actor" \ +ADP_BROWSER_ASSISTANT="$assistant" \ +ADP_BROWSER_MEMBER="$member" \ +ADP_BROWSER_PASSWORD="$password" \ +ADP_BROWSER_TEAM="$team_code" \ +ADP_BROWSER_MONTH="$month" \ +ADP_BROWSER_SCREENSHOT="$screenshot" \ +node "$(dirname "$0")/js/browser-ddev-smoke.mjs" + +if [[ ! -s "$screenshot" ]]; then + echo 'Der genehmigte Plan wurde nicht als temporärer Sichtnachweis erfasst.' >&2 + exit 1 +fi + +cleanup +trap - EXIT +run_probe assert-clean "$team_code" "$month" >/dev/null +for uid in "$actor" "$assistant" "$member"; do + if occ user:info "$uid" >/dev/null 2>&1; then + echo "Synthetischer Nutzer verblieb nach der Bereinigung: $uid" >&2 + exit 1 + fi +done +if occ group:info "$team_group" >/dev/null 2>&1; then + echo "Synthetische Gruppe verblieb nach der Bereinigung: $team_group" >&2 + exit 1 +fi + +echo 'AdPlaner selbstbereinigende Browser-Abnahme: OK' diff --git a/tests/integration/MonthPlanStatusSmoke.php b/tests/integration/MonthPlanStatusSmoke.php index 57953e0..86e430a 100644 --- a/tests/integration/MonthPlanStatusSmoke.php +++ b/tests/integration/MonthPlanStatusSmoke.php @@ -5,14 +5,41 @@ require_once dirname(__DIR__, 4) . '/lib/base.php'; use OCA\AdPlaner\Repository\ShiftPlanRepository; +use OCA\AdPlaner\Model\Team; +use OCA\AdPlaner\Service\ScheduleService; +use OCA\AdPlaner\Store\ShiftPlanStore; use OCP\IDBConnection; -$teamCode = 'RC6STATUS'; -$month = '2099-01'; +$teamCode = 'TXSTATUS'; +$month = '2099-02'; $db = \OC::$server->get(IDBConnection::class); $repository = \OC::$server->get(ShiftPlanRepository::class); +$store = \OC::$server->get(ShiftPlanStore::class); +$service = \OC::$server->get(ScheduleService::class); +$team = new Team($teamCode, 'ad-ASN-' . $teamCode, 'Transaktionstest', [], true, [ + 'shifts' => [[ + 'key' => 'early', + 'label' => 'Früh', + 'startsAt' => '08:00', + 'endsAt' => '14:00', + 'enabled' => true, + ]], +]); $cleanup = static function () use ($db, $teamCode, $month): void { + $qb = $db->getQueryBuilder(); + $qb->delete('adp_day_notes') + ->where($qb->expr()->eq('team_code', $qb->createNamedParameter($teamCode))) + ->andWhere($qb->expr()->gte('work_date', $qb->createNamedParameter($month . '-01'))) + ->andWhere($qb->expr()->lte('work_date', $qb->createNamedParameter($month . '-28'))); + $qb->executeStatement(); + + $qb = $db->getQueryBuilder(); + $qb->delete('adp_shift_slots') + ->where($qb->expr()->eq('team_code', $qb->createNamedParameter($teamCode))) + ->andWhere($qb->expr()->eq('plan_month', $qb->createNamedParameter($month))); + $qb->executeStatement(); + $qb = $db->getQueryBuilder(); $qb->delete('adp_month_plans') ->where($qb->expr()->eq('team_code', $qb->createNamedParameter($teamCode))) @@ -22,20 +49,60 @@ $cleanup(); try { + try { + $store->transactional(static function () use ($store, $teamCode, $month): void { + $store->insertSlot($teamCode, $month, $month . '-01', 'rollback', 'Rollback', '00:00', '01:00', true); + throw new RuntimeException('Erwarteter Rollback-Testabbruch.'); + }); + throw new RuntimeException('Die Rollback-Testtransaktion wurde unerwartet abgeschlossen.'); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'Erwarteter Rollback-Testabbruch.') { + throw $exception; + } + } + if ($store->slotsForMonth($teamCode, $month) !== []) { + throw new RuntimeException('Eine fehlgeschlagene Transaktion hat einen Slot zurückgelassen.'); + } + if ($repository->monthStatus($teamCode, $month) !== null) { throw new RuntimeException('Ein neuer Monatsplan besitzt unerwartet einen persistierten Status.'); } - if (!$repository->transitionMonthStatus($teamCode, $month, 'draft', 'planned', 'rc6-test')) { - throw new RuntimeException('Der erste Statusübergang wurde nicht atomar persistiert.'); + if ($service->transitionMonthStatus($team, $month, 'planned', 'transaction-test') !== 'planned') { + throw new RuntimeException('Der erste Statusübergang wurde nicht persistiert.'); } - if ($repository->transitionMonthStatus($teamCode, $month, 'draft', 'approved', 'rc6-test')) { - throw new RuntimeException('Ein veralteter Ausgangsstatus konnte den Plan überschreiben.'); + if ($service->transitionMonthStatus($team, $month, 'approved', 'transaction-test') !== 'approved') { + throw new RuntimeException('Die Genehmigung wurde nicht persistiert.'); } - if ($repository->monthStatus($teamCode, $month) !== 'planned') { - throw new RuntimeException('Der persistierte Status ist nach einem Konflikt nicht erhalten geblieben.'); + if ($repository->monthStatus($teamCode, $month) !== 'approved') { + throw new RuntimeException('Der persistierte Monatsstatus ist nicht genehmigt.'); } - if (!$repository->transitionMonthStatus($teamCode, $month, 'planned', 'approved', 'rc6-test')) { - throw new RuntimeException('Die Genehmigung wurde nicht persistiert.'); + $slots = $store->slotsForMonth($teamCode, $month); + if (count($slots) !== 28) { + throw new RuntimeException('Der genehmigte Februar-Snapshot enthält nicht genau 28 Schichten.'); + } + foreach ($slots as $slot) { + if ($slot->segmentKey !== 'early' || !$slot->enabled) { + throw new RuntimeException('Der genehmigte Snapshot enthält eine unerwartete Schichtdefinition.'); + } + } + + $qb = $db->getQueryBuilder(); + $qb->select('revision') + ->from('adp_month_plans') + ->where($qb->expr()->eq('team_code', $qb->createNamedParameter($teamCode))) + ->andWhere($qb->expr()->eq('plan_month', $qb->createNamedParameter($month))); + $row = $qb->executeQuery()->fetchAssociative(); + if ($row === false || (int)$row['revision'] < 2) { + throw new RuntimeException('Die Monatsrevision wurde durch Planung und Genehmigung nicht fortgeschrieben.'); + } + + try { + $service->saveDayNote($team, $month, $month . '-01', 'Darf nicht gespeichert werden', 'transaction-test'); + throw new RuntimeException('Ein genehmigter Plan akzeptierte nachträglich eine Bemerkung.'); + } catch (DomainException) { + } + if ($store->dayNotesForMonth($teamCode, $month) !== []) { + throw new RuntimeException('Der abgewiesene Schreibversuch hat eine Bemerkung zurückgelassen.'); } } finally { $cleanup(); diff --git a/tests/integration/PrivacyRuntimeProbe.php b/tests/integration/PrivacyRuntimeProbe.php new file mode 100644 index 0000000..268bfdd --- /dev/null +++ b/tests/integration/PrivacyRuntimeProbe.php @@ -0,0 +1,149 @@ +get(IDBConnection::class); + +$monthRow = static function () use ($db, $teamCode, $month): ?array { + $qb = $db->getQueryBuilder(); + $qb->select('revision', 'updated_by_uid', 'updated_at') + ->from('adp_month_plans') + ->where($qb->expr()->eq('team_code', $qb->createNamedParameter($teamCode))) + ->andWhere($qb->expr()->eq('plan_month', $qb->createNamedParameter($month))); + $row = $qb->executeQuery()->fetchAssociative(); + + if ($row === false) { + return null; + } + + return [ + 'revision' => (int)$row['revision'], + 'updatedByUid' => (string)$row['updated_by_uid'], + 'updatedAt' => $row['updated_at'] instanceof DateTimeInterface + ? $row['updated_at']->format('Y-m-d H:i:s') + : (string)$row['updated_at'], + ]; +}; + +$rowCount = static function (string $table, array $conditions) use ($db): int { + $qb = $db->getQueryBuilder(); + $qb->select($qb->func()->count('*', 'row_count'))->from($table); + foreach ($conditions as $column => $value) { + $condition = $qb->expr()->eq($column, $qb->createNamedParameter($value)); + $qb->andWhere($condition); + } + + return (int)$qb->executeQuery()->fetchOne(); +}; + +switch ($mode) { + case 'assert-no-browser-data': + $remaining = []; + foreach (['adp_month_plans', 'adp_day_notes'] as $table) { + $qb = $db->getQueryBuilder(); + $qb->select($qb->func()->count('*', 'row_count')) + ->from($table) + ->where($qb->expr()->like('updated_by_uid', $qb->createNamedParameter('adp-browser-%'))); + $count = (int)$qb->executeQuery()->fetchOne(); + if ($count > 0) { + $remaining[$table] = $count; + } + } + if ($remaining !== []) { + throw new RuntimeException('Browser-Smoke-Daten verblieben: ' . json_encode($remaining, JSON_THROW_ON_ERROR)); + } + echo "Keine Browser-Smoke-Daten verblieben.\n"; + break; + + case 'snapshot': + $row = $monthRow(); + if ($row === null) { + throw new RuntimeException('Der synthetische Monatsplan fehlt.'); + } + echo json_encode($row, JSON_THROW_ON_ERROR) . PHP_EOL; + break; + + case 'assert-note-present': + if ($workDate === '' || $rowCount('adp_day_notes', ['team_code' => $teamCode, 'work_date' => $workDate]) !== 1) { + throw new RuntimeException('Die synthetische Tagesbemerkung wurde nicht eindeutig persistiert.'); + } + echo "Synthetische Tagesbemerkung ist physisch vorhanden.\n"; + break; + + case 'assert-note-absent': + if ($workDate === '' || $rowCount('adp_day_notes', ['team_code' => $teamCode, 'work_date' => $workDate]) !== 0) { + throw new RuntimeException('Die geleerte Tagesbemerkung besitzt weiterhin eine Datenbankzeile.'); + } + echo "Geleerte Tagesbemerkung besitzt keine Datenbankzeile.\n"; + break; + + case 'cleanup': + $qb = $db->getQueryBuilder(); + $qb->select('id')->from('adp_shift_slots') + ->where($qb->expr()->eq('team_code', $qb->createNamedParameter($teamCode))); + $slotIds = array_map('intval', $qb->executeQuery()->fetchFirstColumn()); + if ($slotIds !== []) { + $qb = $db->getQueryBuilder(); + $qb->delete('adp_shift_candidates') + ->where($qb->expr()->in('slot_id', $qb->createNamedParameter($slotIds, IQueryBuilder::PARAM_INT_ARRAY))); + $qb->executeStatement(); + } + + foreach ([ + ['adp_day_notes', ['team_code' => $teamCode]], + ['adp_shift_slots', ['team_code' => $teamCode]], + ['adp_month_plans', ['team_code' => $teamCode]], + ['adp_team_settings', ['team_code' => $teamCode]], + ] as [$table, $conditions]) { + $qb = $db->getQueryBuilder(); + $qb->delete($table); + foreach ($conditions as $column => $value) { + $qb->andWhere($qb->expr()->eq($column, $qb->createNamedParameter($value))); + } + $qb->executeStatement(); + } + echo "Synthetische AdPlaner-Daten wurden bereinigt.\n"; + break; + + case 'assert-clean': + $remaining = []; + foreach ([ + 'adp_day_notes' => ['team_code' => $teamCode], + 'adp_shift_slots' => ['team_code' => $teamCode], + 'adp_month_plans' => ['team_code' => $teamCode], + 'adp_team_settings' => ['team_code' => $teamCode], + ] as $table => $conditions) { + $count = $rowCount($table, $conditions); + if ($count > 0) { + $remaining[$table] = $count; + } + } + if ($remaining !== []) { + throw new RuntimeException('Synthetische AdPlaner-Daten verblieben: ' . json_encode($remaining, JSON_THROW_ON_ERROR)); + } + echo "Keine synthetischen AdPlaner-Daten verblieben.\n"; + break; + + default: + throw new InvalidArgumentException('Unbekannter Prüfmodus.'); +} diff --git a/tests/js/admin-smoke.js b/tests/js/admin-smoke.js new file mode 100644 index 0000000..f1e0f50 --- /dev/null +++ b/tests/js/admin-smoke.js @@ -0,0 +1,66 @@ +const assert = require('assert'); + +function element() { + return { + checked: false, + disabled: false, + hidden: true, + className: '', + textContent: '', + listeners: {}, + classList: { add() {} }, + addEventListener(type, listener) { this.listeners[type] = listener; } + }; +} + +const confirmation = element(); +const button = element(); +button.disabled = true; +const notice = element(); +const requests = []; +let resolveRequest; +const pendingResponse = new Promise(resolve => { resolveRequest = resolve; }); + +global.document = { + getElementById(id) { + return { + 'adp-demo-confirm': confirmation, + 'adp-demo-install': button, + 'adp-demo-notice': notice, + }[id] || null; + } +}; +global.window = { + LocalBase: { + api: { + ApiClient: class { + async request(path, options) { + requests.push({ path, options }); + return pendingResponse; + } + } + } + } +}; + +require('../../js/admin.js'); + +(async () => { + confirmation.checked = true; + confirmation.listeners.change(); + assert.strictEqual(button.disabled, false); + const firstClick = button.listeners.click(); + const secondClick = button.listeners.click(); + assert.deepStrictEqual(requests, [{ + path: '/api/admin/demo-pack/install', + options: { method: 'POST', body: '{"confirmed":true}' } + }]); + resolveRequest({ result: { teams: ['A', 'B', 'C'] } }); + await Promise.all([firstClick, secondClick]); + assert.strictEqual(confirmation.checked, false); + assert.strictEqual(button.disabled, true); + console.log('AdPlaner admin smoke test passed.'); +})().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/tests/js/assignment-control-smoke.js b/tests/js/assignment-control-smoke.js index 6ab9919..4db2ca5 100644 --- a/tests/js/assignment-control-smoke.js +++ b/tests/js/assignment-control-smoke.js @@ -23,13 +23,19 @@ const html = assignmentControl.render( assert(html.includes('data-assignment-control="7"')); assert(html.includes('data-action="open-assignment-picker"')); -assert(html.includes('value="alice"')); -assert(!html.includes('value="bob"')); -assert(!html.includes('value="eb"')); -assert(html.includes('value="chris"')); +assert(html.includes('aria-label="Assistenz zuteilen"')); +assert(html.includes('data-assignment-trigger="7"')); +assert(html.includes('aria-expanded="false"')); +assert(html.includes('class="adp-assignment-picker"')); +assert(html.includes('role="group"')); +assert(html.includes('aria-label="Assistenz auswählen"')); +assert(html.includes('data-action="add-selected" data-slot-id="7" data-target-uid="alice"')); +assert(!html.includes('data-target-uid="bob"')); +assert(!html.includes('data-target-uid="eb"')); +assert(html.includes('data-target-uid="chris"')); assert(html.includes('<Chris & Co>')); assert(!html.includes('')); -assert(!html.includes('
'), 'Month-plan column headings need an explicit scope.'); +assert(assistantHtml.includes('
Tag${esc(segment.label)}${esc(segment.startsAt)}-${esc(segment.endsAt)}BemerkungenTag${esc(segment.label)}${esc(segment.startsAt)}-${esc(segment.endsAt)}Bemerkungen
${dayHeader(day)}${esc(dateShort(day.date))}${renderHints(day.hints || [])}${dayHeader(day)}${esc(dateShort(day.date))}${renderHints(day.hints || [])}${renderDayNoteControl(day, canCoordinate && mutable)}
Tag'), 'Each planning day needs an explicit row heading.'); assert(assistantHtml.includes('Mi1')); assert(assistantHtml.includes('data-action="add-self" data-slot-id="10"')); assert(!assistantHtml.includes('data-action="add-self" data-slot-id="11"')); @@ -85,8 +87,11 @@ const ebHtml = monthPlan.render({ assert(!ebHtml.includes('data-action="add-self"')); assert(ebHtml.includes('adp-assignment-control')); assert(ebHtml.includes('data-action="remove-candidate" data-slot-id="11" data-target-uid="assistant-a"')); -assert(ebHtml.includes('')); -assert(ebHtml.includes('value="assistant-b"')); +assert(ebHtml.includes('aria-label="Assistant A entfernen"')); +assert(ebHtml.includes('')); +assert(ebHtml.includes('aria-label="Bemerkung für 01.07."'), 'The editable day note needs its own accessible name.'); +assert(ebHtml.includes('aria-label="Bemerkung für 01.07. speichern"')); +assert(ebHtml.includes('data-action="add-selected" data-slot-id="10" data-target-uid="assistant-b"')); assert(ebHtml.includes('Entwurf')); assert(ebHtml.includes('data-action="transition-status" data-target-status="planned"')); @@ -107,4 +112,19 @@ assert(!approvedHtml.includes('adp-assignment-control')); assert(!approvedHtml.includes('data-action="remove-candidate"')); assert(!approvedHtml.includes(' { resolve = done; }); + return resolve; + } + + async state() { + if (this.failState) throw new Error('Zustand nicht verfügbar'); + return { + currentUser: { uid: 'test-eb' }, + teams: [{ code: 'A1', displayName: 'Team A1' }], + organization: {} + }; + } + + async monthPlan(teamCode, month) { + return { month, team: { code: teamCode, displayName: 'Team A1' } }; + } +} + +function createApp(repository, errors) { + const app = new window.ADPlaner.PlanApp({ + repository, + PlanChrome: ChromeFake, + PlanPanel: PanelFake, + byId() { return null; }, + esc: String, + showNotice() {}, + showError(error, fallback) { errors.push({ message: error.message, fallback }); }, + renderMonth() { return ''; }, + renderSettings() { return ''; }, + addShiftRow() {}, + removeShiftRow() {}, + collectShifts() { return []; }, + openAssignmentPicker() {}, + }); + app.state = { + currentUser: { uid: 'test-eb' }, + teams: [{ code: 'A1', displayName: 'Team A1' }], + selectedTeamCode: 'A1', + month: '2026-08', + activeView: 'settings', + monthPlan: { month: '2026-08', team: { code: 'A1' } }, + organization: {}, + loading: false, + }; + return app; +} + +const values = { + displayName: 'Team A1', + meetingDay: '2026-08-10', + shifts: [{ key: 'day', label: 'Tag', startsAt: '08:00', endsAt: '16:00', enabled: true }] +}; + +(async () => { + const errors = []; + const repository = new SettingsRepositoryFake(); + const app = createApp(repository, errors); + + repository.failState = true; + await app.saveSettings(values); + assert.strictEqual(repository.saveCalls, 1); + assert.strictEqual(errors.at(-1).fallback, 'Die Einstellungen wurden gespeichert, aber die Ansicht konnte nicht neu geladen werden.'); + assert.strictEqual(app.state.monthPlan, null); + + repository.failState = false; + repository.failSave = true; + await app.saveSettings(values); + assert.strictEqual(repository.saveCalls, 2); + assert.strictEqual(errors.at(-1).fallback, 'Einstellungen konnten nicht gespeichert werden.'); + + repository.failSave = false; + const resolveSave = repository.deferSave(); + const firstSave = app.saveSettings(values); + await Promise.resolve(); + const secondSave = app.saveSettings(values); + await Promise.resolve(); + assert.strictEqual(repository.saveCalls, 3, 'A second submit must not start another settings write while one is pending.'); + resolveSave(); + await Promise.all([firstSave, secondSave]); + repository.pendingSave = null; + assert.strictEqual(app.settingsSaving, false); + + repository.failSave = true; + await app.saveSettings(values); + assert.strictEqual(repository.saveCalls, 4, 'Saving must be possible again after the pending request completed.'); + + console.log('AdPlaner settings workflow smoke test passed.'); +})().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/tests/js/settings-panel-smoke.js b/tests/js/settings-panel-smoke.js index d463b95..8b9ae0d 100644 --- a/tests/js/settings-panel-smoke.js +++ b/tests/js/settings-panel-smoke.js @@ -42,11 +42,14 @@ const editorHtml = settingsPanel.render({ }); assert(editorHtml.includes('id="settings-form"')); +assert(editorHtml.includes('name="displayName" type="text" maxlength="255" required')); assert(editorHtml.includes('value="Team <Settings>"')); assert(editorHtml.includes('value="early"')); assert(editorHtml.includes('value="Früh <A>"')); assert(editorHtml.includes('data-action="add-shift-row"')); assert(editorHtml.includes('data-action="remove-shift-row"')); +assert(editorHtml.includes('aria-label="Schicht Früh <A> entfernen"')); +assert(!editorHtml.includes('aria-label="Schicht Früh entfernen"')); assert(editorHtml.includes('')); const collectForm = { diff --git a/tests/privacy-ddev-smoke.sh b/tests/privacy-ddev-smoke.sh new file mode 100755 index 0000000..fe6b2f9 --- /dev/null +++ b/tests/privacy-ddev-smoke.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_url="${ADP_BASE_URL:-https://nextcloud-dev.ddev.site}" +ddev_project="${ADP_DDEV_PROJECT:-$(cd "$(dirname "$0")/../../nextcloud-dev" && pwd)}" +suffix="$(date +%s)$$" +team_code="P${suffix: -15}" +team_group="ad-ASN-$team_code" +month='2098-11' +work_date="$month-03" +password="$(php -r 'echo bin2hex(random_bytes(24));')" +actor="adp-privacy-$suffix-eb" +active="adp-privacy-$suffix-active" +disabled="adp-privacy-$suffix-disabled" +created_users=() +workdir="$(mktemp -d)" +probe='/var/www/html/html/custom_apps/adplaner/tests/integration/PrivacyRuntimeProbe.php' + +occ() { + (cd "$ddev_project" && ddev exec -d /var/www/html/html php occ "$@") +} + +run_probe() { + (cd "$ddev_project" && ddev exec -d /var/www/html/html php "$probe" "$@") +} + +cleanup() { + local failed=0 + run_probe cleanup "$team_code" "$month" || failed=1 + local uid + for uid in "${created_users[@]}"; do + occ user:delete "$uid" >/dev/null 2>&1 || failed=1 + done + occ group:delete "$team_group" >/dev/null 2>&1 || failed=1 + rm -rf "$workdir" + return "$failed" +} + +report_failed_cleanup() { + echo 'Der Datenschutz-Smoke wurde abgebrochen; die automatische Bereinigung war nicht vollständig erfolgreich.' >&2 + echo "Bitte synthetische Objekte prüfen: Team $team_code, Gruppe $team_group, Nutzer ${created_users[*]:-keine}." >&2 +} + +trap 'cleanup || report_failed_cleanup' EXIT + +create_user() { + local uid="$1" + (cd "$ddev_project" && ddev exec -d /var/www/html/html env OC_PASS="$password" php occ user:add --password-from-env "$uid") >/dev/null + created_users+=("$uid") + occ group:adduser "$team_group" "$uid" >/dev/null +} + +if ! occ group:info ad-EB >/dev/null 2>&1; then + echo 'Die bestehende EB-Rollengruppe ad-EB fehlt; der Smoke verändert die Organisationskonfiguration nicht.' >&2 + exit 1 +fi + +occ group:add "$team_group" >/dev/null +create_user "$actor" +create_user "$active" +create_user "$disabled" +occ group:adduser ad-EB "$actor" >/dev/null +occ user:disable "$disabled" >/dev/null + +page="$workdir/page.html" +cookies="$workdir/cookies.txt" +plan="$workdir/plan.json" +response="$workdir/response.json" + +curl --fail --silent --show-error --insecure --user "$actor:$password" \ + --cookie-jar "$cookies" "$base_url/index.php/apps/adplaner/" --output "$page" +token="$(sed -n 's/.*data-requesttoken="\([^"]*\)".*/\1/p' "$page" | head -n 1)" +if [[ -z "$token" ]]; then + echo 'Request-Token fehlt.' >&2 + exit 1 +fi + +plan_endpoint="$base_url/index.php/apps/adplaner/api/teams/$team_code/months/$month" +curl --fail --silent --show-error --insecure --user "$actor:$password" \ + --cookie "$cookies" --cookie-jar "$cookies" -H "requesttoken: $token" \ + "$plan_endpoint" --output "$plan" + +php -r ' +$data = json_decode(file_get_contents($argv[1]), true, flags: JSON_THROW_ON_ERROR); +$uids = array_column($data["team"]["assistants"] ?? [], "uid"); +if (!in_array($argv[2], $uids, true)) { + throw new RuntimeException("Die aktive Assistenz fehlt im öffentlichen Team-Payload."); +} +foreach ([$argv[3], $argv[4]] as $forbiddenUid) { + if (in_array($forbiddenUid, $uids, true)) { + throw new RuntimeException("Ein nicht schichtfähiges Konto wurde öffentlich als Assistenz angeboten: " . $forbiddenUid); + } +} +' "$plan" "$active" "$disabled" "$actor" + +before="$(run_probe snapshot "$team_code" "$month")" +curl --fail --silent --show-error --insecure --user "$actor:$password" \ + --cookie "$cookies" --cookie-jar "$cookies" -H "requesttoken: $token" \ + "$plan_endpoint" --output "$plan" +after="$(run_probe snapshot "$team_code" "$month")" +php -r ' +$before = json_decode($argv[1], true, flags: JSON_THROW_ON_ERROR); +$after = json_decode($argv[2], true, flags: JSON_THROW_ON_ERROR); +if ($after["revision"] <= $before["revision"]) { + throw new RuntimeException("Das technische Monats-Locking hat die Revision nicht fortgeschrieben."); +} +if ($after["updatedByUid"] !== $before["updatedByUid"] || $after["updatedAt"] !== $before["updatedAt"]) { + throw new RuntimeException("Das technische Monats-Locking hat fachliche Änderungsmetadaten überschrieben."); +} +' "$before" "$after" + +note_endpoint="$plan_endpoint/days/$work_date/note" +curl --fail --silent --show-error --insecure --user "$actor:$password" \ + --cookie "$cookies" --cookie-jar "$cookies" -H "requesttoken: $token" -H 'Content-Type: application/json' \ + -X POST --data '{"note":"Synthetische Prüfbemerkung"}' "$note_endpoint" --output "$response" +run_probe assert-note-present "$team_code" "$month" "$work_date" >/dev/null + +curl --fail --silent --show-error --insecure --user "$actor:$password" \ + --cookie "$cookies" --cookie-jar "$cookies" -H "requesttoken: $token" -H 'Content-Type: application/json' \ + -X POST --data '{"note":" "}' "$note_endpoint" --output "$response" +run_probe assert-note-absent "$team_code" "$month" "$work_date" >/dev/null + +cleanup +trap - EXIT +run_probe assert-clean "$team_code" "$month" >/dev/null +for uid in "$actor" "$active" "$disabled"; do + if occ user:info "$uid" >/dev/null 2>&1; then + echo "Synthetischer Nutzer verblieb nach der Bereinigung: $uid" >&2 + exit 1 + fi +done +if occ group:info "$team_group" >/dev/null 2>&1; then + echo "Synthetische Gruppe verblieb nach der Bereinigung: $team_group" >&2 + exit 1 +fi + +echo 'AdPlaner datensparsamer DDEV-Runtime-Smoke: OK' diff --git a/tests/run-js.mjs b/tests/run-js.mjs index a2efa1d..8426e6e 100644 --- a/tests/run-js.mjs +++ b/tests/run-js.mjs @@ -7,10 +7,12 @@ const root = dirname(dirname(fileURLToPath(import.meta.url))); runJavaScriptSuite({ root, testFiles: [ + 'tests/js/admin-smoke.js', 'tests/js/assignment-control-smoke.js', 'tests/js/main-workflow-smoke.js', 'tests/js/model-smoke.js', 'tests/js/month-plan-smoke.js', + 'tests/js/plan-app-settings-smoke.js', 'tests/js/plan-repository-smoke.js', 'tests/js/settings-panel-smoke.js', 'tests/js/ui-smoke.js', diff --git a/tests/run.php b/tests/run.php index 5e4af37..7bc8b9d 100644 --- a/tests/run.php +++ b/tests/run.php @@ -2,7 +2,7 @@ declare(strict_types=1); -require_once __DIR__ . '/../../localbase/tests/Support/PhpTestRunner.php'; +require_once __DIR__ . '/bootstrap.php'; use OCA\LocalBase\Tests\Support\PhpTestRunner;