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.
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index be0b5d0..7170d92 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -186,3 +186,12 @@ jobs:
run: |
php tests/run.php
node tests/run-js.mjs
+
+ deploy-staging:
+ name: Staging-Deployment
+ if: github.event_name == 'push'
+ needs: [php, javascript, consumer-contracts]
+ uses: Filzmann/br-nextcloud-apps/.github/workflows/deploy-staging.yml@main
+ with:
+ app-id: localbase
+ secrets: inherit
diff --git a/AGENTS.md b/AGENTS.md
index da2eedc..15acd73 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,7 +23,7 @@ Aktuell enthalten:
- PHP-Modelltrait `OCA\LocalBase\Model\ModelApiTrait`.
- PHP-Logger `OCA\LocalBase\Service\AppLogger` fuer sichere, skalare Log-Kontexte mit App-ID und optionaler User-ID.
- PHP-Gruppenhelfer `OCA\LocalBase\Service\GroupProvisioningService` zum idempotenten Anlegen beliebiger Nextcloud-Gruppen.
-- Neutraler Kalendervertrag `AbsenceQueryEvent`/`AbsenceInterval` fuer optionale, read-only Abwesenheitsprovider. `planned` liefert `U?` ohne Blockade, `approved` liefert `U` mit Blockade.
+- Neutraler Kalendervertrag `AbsenceEmployeeDiscoveryEvent`/`AbsenceQueryEvent`/`AbsenceInterval` fuer optionale, read-only Abwesenheitsprovider. Die Discovery bleibt auf einen halboffenen Zeitraum begrenzt, liefert ausschließlich normalisierte Konto-UIDs und bleibt ohne Provider leer. `planned` liefert `U?` ohne Blockade, `approved` liefert `U` mit Blockade.
- `CalendarContext` und `CalendarContextSettingsService` definieren Land, ISO-3166-2-Region und fachliche IANA-Zeitzone organisationsweit. `DE`, `DE-BE` und `Europe/Berlin` bleiben Bestandsdefaults. Persönliche Nextcloud-Zeitzonen dürfen ausschließlich individuelle Terminanzeigen beeinflussen. Der Kontext ist im gemeinsamen AD-Adminbereich änderbar und wird bei bestehenden persönlichen Dashboardlayouts additiv eingeblendet.
- `HolidayCalendarService` liefert Schulferien und gesetzliche Feiertage als validierten, read-only Jahresvertrag für den gemeinsamen Kalenderkontext. `OpenHolidaysClient` ist der einzige externe Provideradapter; `HolidayCalendarCacheStore` hält regionsgebundene Jahresstände in der LocalBase-AppConfig. Ein täglicher Hintergrundjob aktualisiert das laufende und die zwei folgenden Jahre. Bei Providerfehlern bleibt ein vorhandener Stand als `stale` verfügbar, Erstabrufe werden sicher als `unavailable` ausgewiesen und nach kurzer Sperrfrist erneut versucht.
- `AdOrganizationDefinition`, `AdOrganizationSettingsService`, `AdOrganizationHierarchy` und `AdOrganizationPermissionPolicy` bilden die konfigurierbaren gemeinsamen AD-Gruppen, Anzeigenamen, Bereiche, Teamansichten, Hierarchie und Peer-Grenzen fuer Kalender, Urlaub und Assistenzplanung ab.
@@ -31,6 +31,7 @@ Aktuell enthalten:
- Rollen und Bereiche werden über stabile semantische Schlüssel referenziert; konfigurierbare Nextcloud-Gruppen-IDs oder Anzeigenamen dürfen nicht als Fachschlüssel in App-Code dupliziert werden.
- Die initiale Reihenfolge umfasst Fahrzeugverwaltung nach IT, Empfang nach Sekretariat sowie im Pflegebereich stellvertretende PDL, Büroorganisation Pflege und Pflegefachkraft. Für den Bürobereich bleibt Büroleitung, stellvertretende Büroleitung, Einsatzbegleitung und Büromitarbeiter*innen maßgeblich. Die im Adminbereich gespeicherte Reihenfolge bleibt für alle Verbraucher verbindlich.
- Organisationsvertrag Version 2 ergänzt bestehende Version-1-Einstellungen additiv um `deputy_pdl`, `care_office`, `fleet_management` und `reception`, die freigegebenen Hierarchiekanten sowie Urlaubsansichten. Bestehende Werte und Kanten bleiben erhalten; Gruppen-ID-Kollisionen und Zyklen werden abgelehnt.
+- Organisationsvertrag Version 3 trennt `finance` und `payroll` additiv unter `finance_lead`; die bestehende Finanzgruppen-ID bleibt erhalten. Der read-only `AdOrganizationSnapshot` enthält nur Rollen und Bereiche, keine Mitgliederlisten, und ist bei fehlender oder ungültiger Persistenz nicht freigabefähig.
- `diagramOrder` speichert davon getrennt ausschließlich die globale Links-rechts-Anordnung der Organigrammkarten innerhalb ihrer Hierarchieebene. Beim horizontalen Drag-and-drop bestimmt der Zwischenraum zwischen zwei Karten die neue Einfügeposition. Diese visuelle Anordnung verändert weder Rollen-/Bereichsreihenfolgen noch Kalender, Rechte oder Hierarchiekanten.
- Das Organigramm bleibt automatisch nach Hierarchieebenen angeordnet; freie X-/Y-Knotenpositionen sind kein Bestandteil des Organisationsvertrags. Karten derselben Ebene stehen waagerecht nebeneinander und verwenden innerhalb definierter Mindest-/Maximalgrenzen nur ihre benötigte Breite; sie brechen nicht in scheinbare zusätzliche Hierarchiezeilen um. Der persönliche Zoom wird in 10-Prozent-Schritten von 50 bis 150 Prozent über `IUserConfig` geräteübergreifend gespeichert. Der verschobene Ausschnitt bleibt wegen unterschiedlicher Viewportgrößen flüchtig. Zoom und Ausschnitt verändern weder Hierarchie und Diagrammordnung noch die logische Größe der Exporte.
- Fachliche Rolleneinstellungen werden über den Edit-Stift der Diagrammkarten in einem zugänglichen Seitenpanel bearbeitet und gelten für alle Diagrammkarten derselben semantischen Rolle. Technische Gruppen-IDs bleiben dort eingeklappt; die für Kalender und Gruppenlisten verbindliche Rollenreihenfolge bleibt als eigene kompakte Drag-and-drop-Liste sichtbar. Bürobereiche und Urlaubsansichten werden als aufklappbare Einstellungskarten dargestellt.
@@ -42,7 +43,15 @@ Aktuell enthalten:
- Ungültige Referenzen, doppelte Gruppen-IDs und Hierarchiezyklen werden beim Speichern abgelehnt. Eine ungültige persistierte Definition fällt beim Lesen sicher auf die geprüfte Standarddefinition zurück.
- `ScheduleConflictQueryEvent` liefert vor genehmigten Abwesenheiten read-only Konflikte aus optional aktivierten Planungsapps; Provider loeschen oder aendern dabei keine Daten.
- `IntegrationCapabilityQueryEvent`, `AdIntegrationCapabilities` und `IntegrationCapabilityService` beschreiben optionale Cross-App-Fähigkeiten. Ein leerer Snapshot ist ein zulässiger Standalone-Zustand und erweitert niemals Berechtigungen.
-- `StandaloneAppNavigationService` registriert Fachapp-Einstiege nur ohne aktive OrgSuite. `AdProductSuiteService` und die dynamischen Settings-Adapter platzieren die gemeinsame Organisationsverwaltung bei einer Einzelinstallation unter deren Fachprodukt.
+- `AdProductCatalog` liest den versionierten AD-Produktkatalog als kanonische
+ Quelle für Produkt-IDs, Reihenfolge, Routen sowie getrennte Menü-,
+ Standalone- und Bundle-Eigenschaften. Ungültige oder fehlende Katalogdaten
+ erweitern weder Navigation noch Berechtigungen.
+- `StandaloneAppNavigationService` registriert katalogisierte
+ Fachapp-Einstiege nur ohne aktive OrgSuite. `AdProductSuiteService` und die
+ dynamischen Settings-Adapter platzieren die gemeinsame
+ Organisationsverwaltung bei einer Einzelinstallation unter deren
+ Fachprodukt.
- Organisationseditor, Admin-API und Persistenz des gemeinsamen AD-Vertrags liegen vollständig in LocalBase. OrgSuite bindet diese Oberfläche ab zwei Produkten nur als Adminadapter ein.
- JavaScript-Basisklasse `window.LocalBase.models.Model`.
- JavaScript-API-Client `window.LocalBase.api.ApiClient`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10bed27..9816742 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,22 @@
# Changelog
+## 0.10.0-rc.2
+
+- Gemeinsamen API-Client um den lokalisierten Nextcloud-Fehlervertrag mit stabilem `error`-Feld ergänzt; ältere `message`-Antworten bleiben kompatibel.
+
+## 0.10.0-rc.1
+
+- Organisationsvertrag Version 3 mit getrennten Rollen `finance` und `payroll` ergänzt; bestehende Finanzgruppen bleiben beim Upgrade erhalten.
+- Datensparsamen, unveränderlichen Organisationssnapshot für Fachapp-Berechtigungen veröffentlicht.
+- Fehlende oder ungültige Organisationspersistenz im Snapshot explizit als nicht freigabefähig markiert.
+- Synthetische Demoorganisation um die getrennte Lohnrolle ergänzt.
+
+## 0.9.0-rc.1
+
+- Versionierten AD-Produktkatalog als kanonischen Providervertrag ergänzt.
+- AD Recruitment als Standalone-, Menü-, Suite- und Einzelbundle-Produkt aufgenommen.
+- Bestehende Standalone-Navigation auf Katalogroute und -reihenfolge umgestellt.
+
## 0.7.0-rc.1
- Optionale Capability-Verträge für eigenständig installierbare AD-Fachprodukte ergänzt.
diff --git a/README.md b/README.md
index 763e8c9..675ad43 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,11 @@
Gemeinsame Basisbausteine für die lokalen AD- und BR-Nextcloud-Apps. LocalBase besitzt keine eigene Navigation und wird als technische Infrastruktur mit den AD-Fachprodukten ausgeliefert.
+Der öffentliche AD-Organisationsvertrag Version 3 trennt Finanzen und Lohn
+unter derselben Leitung. Fachapps konsumieren Rollen und Bürobereiche über
+einen unveränderlichen, datensparsamen Snapshot; fehlende oder ungültige
+Persistenz erteilt keine fachlichen Rechte.
+
## Staging-Kompatibilität
- Nextcloud 34
@@ -23,3 +28,8 @@ Auf Staging- und Zielsystemen wird LocalBase nicht als separates Fachprodukt ins
## Roadmap
Geplante gemeinsame Bausteine und offene Architekturentscheidungen stehen in der [Roadmap](ROADMAP.md).
+
+Für die manuelle Staging-Prüfung der Administrationsoberfläche und der
+Cross-App-Verträge steht ein ausfüllbares
+[Abnahmeformular](docs/manual-acceptance.md) bereit. Es berücksichtigt, dass
+LocalBase keine eigene Fachnavigation besitzt.
diff --git a/ROADMAP.md b/ROADMAP.md
index 7fce945..73524aa 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,8 +2,42 @@
Diese Datei bündelt geplante Erweiterungen und offene Architekturentscheidungen. Verbindliche Fach-, Sicherheits- und Architekturregeln stehen in `AGENTS.md`.
+## Freigegebene Umsetzungsaufgaben
+
+### LB-BR-GROUPS – Gemeinsamen BR-Gruppenvertrag bereitstellen
+
+Status: bereit nach Klärung der Mitgliedschaftsinvariante
+
+- Konfigurierbare semantische Schlüssel für BR-Mitglieder, Vorsitz und
+ Stellvertretung bereitstellen; die drei Bedeutungen bleiben getrennt.
+- Bestehende Gruppennamen additiv übernehmen. Der Provider benennt oder
+ löscht keine Gruppen und verändert keine Mitgliedschaften.
+- Fehlende, doppelte oder widersprüchliche Gruppenbezüge sicher ablehnen.
+- Vor Implementierung entscheiden, ob Vorsitz und Stellvertretung zwingend
+ zugleich Mitglieder der allgemeinen BR-Gruppe sein müssen.
+- Provider-, Migrations- und Deny-Tests gemeinsam mit
+ `BRT-BR-GROUPS` und `BRS-BR-GROUPS` abnehmen.
+
+## Zukunftsplanung – nicht freigegeben
+
+### LB-L10N – LocalBase-Oberflächen vollständig lokalisieren
+
+Status: später, nicht freigegeben; Pilot-App, Reihenfolge und Rohtext-Gate
+werden vor jeder Umsetzung appübergreifend separat freigegeben
+
+- Nur von LocalBase selbst gerenderte sichtbare Texte, Meldungen,
+ Datumsnamen, Pluralformen und Platzhalter auf Nextcloud-l10n umstellen.
+- Konfigurierte Eigennamen, technische Schlüssel, API-Werte und
+ Organisationsdaten unverändert lassen.
+- Deutsche Ausgabe, eine weitere Locale, Fallback, Pluralformen,
+ Platzhalter und Escaping in PHP und JavaScript testen.
+- Erst nach vollständiger Migration einen Rohtext-Check für LocalBase
+ verbindlich schalten.
+
## Aktueller Fokus
+- Die manuellen Prüfungen werden im ausfüllbaren
+ [`docs/manual-acceptance.md`](docs/manual-acceptance.md) dokumentiert.
- Bestehende gemeinsame Modelle, API-, UI-, Organisations-, Integrations- und Testverträge klein, dependency-arm und stabil halten.
- Öffentliche Verträge mit den betroffenen Consumer-Apps auf einem realitätsnahen Staging und durch Contract-Tests absichern.
- Den Organisationseditor mit realen Gruppenbesetzungen und großen Organisationsstrukturen visuell und fachlich abnehmen.
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 3a89904..ee25c5b 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -5,19 +5,19 @@
Lokale Nextcloud-Basis
Gemeinsame lokale Basisbausteine für eigene Nextcloud-Apps.
Stellt kleine, gemeinsam genutzte PHP- und JavaScript-Basisbausteine für eigene lokale Nextcloud-Apps bereit.
- 0.8.0-rc.2
+ 0.10.0-rc.2
agpl
Simon
+ LocalBase
+ tools
https://github.com/Filzmann/ad-suite
https://github.com/Filzmann/nextcloud-localbase/issues
https://github.com/Filzmann/nextcloud-localbase
- LocalBase
- tools
-
- OCA\LocalBase\BackgroundJob\RefreshHolidayCalendarJob
-
+
+ OCA\LocalBase\BackgroundJob\RefreshHolidayCalendarJob
+
diff --git a/docs/architecture.md b/docs/architecture.md
index 2ea208d..a55882a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -15,9 +15,12 @@ sie isoliert aus.
## Kalender- und Abwesenheitsverträge
-`AbsenceQueryEvent` und `AbsenceInterval` bilden optionale read-only
-Abwesenheitsprovider ab. `planned` liefert `U?` ohne Blockade, `approved`
-liefert `U` mit Blockade. `ScheduleConflictQueryEvent` liefert vor genehmigten
+`AbsenceEmployeeDiscoveryEvent`, `AbsenceQueryEvent` und `AbsenceInterval`
+bilden optionale read-only Abwesenheitsprovider ab. Die Discovery ist an einen
+halboffenen Zeitraum gebunden und aggregiert ausschließlich normalisierte
+Konto-UIDs; leere und nicht-stringförmige Providerwerte werden verworfen, und
+ohne Provider bleibt sie leer. `planned` liefert `U?` ohne Blockade,
+`approved` liefert `U` mit Blockade. `ScheduleConflictQueryEvent` liefert vor genehmigten
Abwesenheiten read-only Konflikte aus optionalen Planungsapps; Provider
löschen oder verändern keine Daten.
@@ -51,6 +54,27 @@ erhalten; Gruppen-ID-Kollisionen, ungültige Referenzen und Hierarchiezyklen
werden abgelehnt. Eine ungültige gespeicherte Definition fällt sicher auf die
geprüfte Standarddefinition zurück.
+Version 3 trennt die bisherigen Funktionen unterhalb `finance_lead` in die
+stabilen Schlüssel `finance` und `payroll`. Beim Upgrade bleibt die bestehende
+Gruppen-ID von `finance` erhalten; `payroll` wird additiv ergänzt. Beide
+Rollen bleiben im bisherigen Hierarchie- und Organisationsblock.
+Aus Sicherheitsgründen wird die Mitgliedschaft der bisherigen kombinierten
+Gruppe nicht automatisch zu `payroll` kopiert: Die Bestandsgruppe wird
+`finance` zugeordnet und ihr unveränderter Standardtitel fachlich zu
+„Finanzen“ normalisiert. Administrator*innen verschieben Lohn-Mitarbeitende
+anschließend bewusst in die neue konfigurierte Lohn-Gruppe. Bis dahin erhält
+niemand aus der alten kombinierten Gruppe Zugriff auf Vertragsstammdaten.
+Für bestehende Hierarchie-Consumer bleibt die frühere technische Gruppen-ID
+`ad-Finanzen-Lohn` als reiner `finance`-Alias lesbar; dieser Alias erteilt
+ausdrücklich niemals die neue `payroll`-Rolle.
+
+`AdOrganizationSnapshotService` veröffentlicht Rollen und Bereiche ohne
+Mitgliederlisten oder Fachrechte. Der unveränderliche Snapshot enthält
+Vertragsversion, Definitionsversion, Gültigkeitsstatus und Prüfsumme. Eine
+fehlende, beschädigte oder nur aus Defaults rekonstruierte Persistenz erzeugt
+einen ungültigen, leeren Snapshot, aus dem Consumer keine Freigabe ableiten
+dürfen.
+
`AdSuiteAdminSettingsService` speichert app-übergreifende Peerfreigaben
semantisch nach Rollen. Die Organisationsdefinition und diese Freigaben liegen
zentral in LocalBase-AppConfig. Bei Einzelinstallation erscheinen sie im
diff --git a/docs/manual-acceptance.md b/docs/manual-acceptance.md
new file mode 100644
index 0000000..a8d0b85
--- /dev/null
+++ b/docs/manual-acceptance.md
@@ -0,0 +1,109 @@
+# Manuelles Abnahmeformular – LocalBase
+
+Dieses Formular dokumentiert die manuelle Abnahme der von LocalBase
+bereitgestellten Infrastruktur-, Administrations- und Cross-App-Verträge auf
+einem realitätsnahen Staging-System. LocalBase besitzt keine eigene
+Fachnavigation; sichtbare Prüfungen erfolgen im jeweils vorgesehenen
+Adminadapter oder in einer ausdrücklich genannten Consumer-App.
+
+Pro Prüffall wird genau ein Ergebnis markiert. Keine Passwörter, Tokens,
+personenbezogenen Echtdaten, vollständigen Mitgliederlisten oder internen
+Systemkennungen eintragen. Ausschließlich neutrale Testkonten und synthetische
+Organisationsdaten verwenden.
+
+## Kopfdaten
+
+| Feld | Eintrag |
+|---|---|
+| Datum und Uhrzeit | |
+| Prüfer*in | |
+| Umgebung und URL | |
+| LocalBase-Version | |
+| Nextcloud-Version | |
+| Browser und Version | |
+| Fenstergröße / Zoom | |
+| Neutrale Admin- und Nichtadmin-Konten | |
+| Aktive Consumer-Apps | |
+| Ausgangskonfiguration gesichert unter | |
+
+Ergebniskennzeichnung: `[ ] erfolgreich` / `[ ] nicht erfolgreich` /
+`[ ] nicht geprüft`. Bei „nicht erfolgreich“ oder „nicht geprüft“ ist eine
+Begründung verpflichtend. Veränderte Testkonfigurationen werden nach der
+Abnahme auf den dokumentierten Ausgangsstand zurückgeführt.
+
+## A. Installation und Administrationsort
+
+| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung |
+|---|---|---|---|---|---|
+| A1 | Technische Infrastruktur-App | App-Status und Nextcloud-Navigation mit aktivem LocalBase prüfen. | LocalBase ist aktiv, besitzt aber keinen eigenen Fach- oder Haupteinstieg. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| A2 | Einzelprodukt-Adminadapter | Genau ein unterstütztes AD-Fachprodukt zusammen mit LocalBase aktivieren und dessen Adminbereich öffnen. | Die gemeinsame Organisationsverwaltung erscheint beim Fachprodukt; OrgSuite ist dafür nicht erforderlich. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| A3 | Mehrprodukt-Adminadapter | Mindestens zwei AD-Fachprodukte und OrgSuite aktivieren und den Nextcloud-Adminbereich öffnen. | Die gemeinsame Organisationsverwaltung erscheint über OrgSuite und nicht mehrfach in den Fachapps. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| A4 | Nichtadmin-Deny | Als Nichtadmin den Adminbereich und einen direkten administrativen Lese- sowie Schreibaufruf versuchen. | Der Zugriff wird serverseitig verweigert; keine Konfiguration ändert sich. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| A5 | CSRF-Schutz | Einen schreibenden Adminaufruf mit Sitzung, aber ohne gültiges Requesttoken wiederholen. | Der Request wird abgewiesen und der vorherige Konfigurationsstand bleibt vollständig erhalten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+
+## B. Organisation, Rechte und persönliche Darstellung
+
+| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung |
+|---|---|---|---|---|---|
+| B1 | Rollen, Bereiche und Reihenfolge | Synthetische Gruppen-IDs, sichtbare Namen, Bürobereiche und Rollenreihenfolge ändern, speichern und neu laden. | Der vollständige gültige Stand bleibt erhalten und wird von den sichtbaren Consumer-Apps einheitlich verwendet. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| B2 | Hierarchie und Peer-Freigabe | Eine neutrale Hierarchiekante und eine Peer-Freigabe ändern und mit passenden Testkonten in einem Consumer positiv sowie negativ prüfen. | Nur die konfigurierte Hierarchie beziehungsweise Freigabe wirkt; Bereichsgrenzen und deny by default bleiben erhalten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| B3 | Ungültige Definition | Doppelte Gruppen-ID, unbekannte Referenz und Hierarchiezyklus nacheinander absenden. | Jeder ungültige Stand wird verständlich abgewiesen; die letzte gültige Definition bleibt unverändert. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| B4 | Sichere Leserückfallebene | In einer dafür vorbereiteten Testinstallation einen ungültigen persistierten Organisationsstand lesen. | Die Oberfläche fällt erkennbar auf die geprüfte Standarddefinition zurück und leitet aus dem ungültigen Stand keine Freigabe ab. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| B5 | Diagrammordnung ohne Fachwirkung | Karten derselben Ebene horizontal umordnen und danach Rollenliste, Kalenderreihenfolge und Rechte prüfen. | Nur die visuelle Links-rechts-Anordnung ändert sich; fachliche Reihenfolge und Rechte bleiben gleich. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| B6 | Persönliches Dashboardlayout | Blöcke per Tastatur und Drag-and-drop verschieben, einklappen und mit einem zweiten Admin-Konto vergleichen. | Reihenfolge und Einklappzustand bleiben je Konto getrennt; neue beziehungsweise unbekannte Blöcke beschädigen das Layout nicht. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| B7 | Persönlicher Zoom | Organigrammzoom zwischen 50 und 150 Prozent ändern und mit einem zweiten Gerät oder Browserfenster desselben Kontos prüfen. | Zoom wird in 10-Prozent-Schritten gespeichert; der verschobene Ausschnitt bleibt flüchtig und die fachliche Struktur unverändert. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| B8 | Tastatur, Fokus und große Struktur | Seitenpanel, Karten, Listen, Blöcke und Organigramm mit Tastatur bedienen; viele synthetische Rollen und Bereiche anzeigen. | Fokus bleibt sichtbar, Funktionen sind erreichbar und die Oberfläche bleibt ohne Tastaturfalle oder unkontrolliertes Seitenscrollen nutzbar. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+
+## C. Kalenderkontext und Ferien-/Feiertagsvertrag
+
+| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung |
+|---|---|---|---|---|---|
+| C1 | Bestandsdefaults | Kalenderkontext ohne bewusst gespeicherte Abweichung öffnen. | `DE`, `DE-BE` und `Europe/Berlin` erscheinen als geprüfte Bestandsdefaults. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| C2 | Gültiger Kontextwechsel | Auf eine freigegebene synthetische Testregion und passende IANA-Zeitzone wechseln und Consumer neu laden. | Der normalisierte Kontext bleibt gespeichert und Ferien, Feiertage sowie fachliche Datumsgrenzen folgen ihm. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| C3 | Ungültiger Kontext | Leeres Land, unpassende Region und ungültige Zeitzone absenden. | Ungültige Kombinationen werden abgewiesen; es entsteht keine Teilkonfiguration. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| C4 | Aktueller Jahresstand | Ein noch nicht geladener, für den Test freigegebener Jahresstand abrufen und danach erneut öffnen. | Validierte Ferien und gesetzliche Feiertage werden geliefert; die Wiederholung nutzt den regionsgebundenen Cache. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| C5 | Veralteter Cache | Mit vorhandenem gültigem Cache den Provider in einer isolierten Testumgebung vorübergehend unerreichbar machen. | Der letzte gültige Stand bleibt verfügbar und wird sichtbar als veraltet gekennzeichnet. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| C6 | Erstabruf ohne Provider | Für eine nicht gecachte Testkombination den Providerausfall simulieren. | Der Stand wird sicher als nicht verfügbar gemeldet und nicht als leere, aber gültige Feiertagsliste ausgegeben. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+
+## D. Öffentliche Verträge und Standalone-Zustände
+
+| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung |
+|---|---|---|---|---|---|
+| D1 | Abwesenheitsvertrag | Für einen begrenzten halboffenen Zeitraum neutrale Konten mit `planned` und `approved` über AD Urlaub entdecken und in AD Kalender lesen. | Die Discovery liefert ausschließlich die passenden normalisierten Konto-UIDs; `planned` erscheint als `U?` ohne Blockade und `approved` als `U` mit der vereinbarten Blockade. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| D2 | Fehlender Abwesenheitsprovider | Consumer ohne AD Urlaub öffnen. | Der leere Providerzustand ist gültig und blockiert die Consumer-App nicht. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| D3 | Konfliktabfrage | Einen genehmigungsrelevanten synthetischen Konflikt zwischen AD Urlaub und AD Kalender prüfen. | Der read-only Vertrag meldet Konflikte, verändert oder löscht aber keine Daten in einer App. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| D4 | Capability-Snapshot | Consumer mit und ohne optionale Provider öffnen und die angebotenen Integrationen vergleichen. | Fähigkeiten entsprechen den aktiven Providern; ein leerer Snapshot bleibt zulässig und erweitert niemals Rechte. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| D5 | Standalone-Navigation | Ein AD-Fachprodukt ohne OrgSuite und anschließend mit aktiver OrgSuite öffnen. | LocalBase liefert genau den vorgesehenen Einzel- beziehungsweise Suite-Einstieg, ohne Fachrechte zu beeinflussen. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| D6 | Consumer-Isolation bei Fehler | Einen optionalen Provider gezielt fehlschlagen lassen und eine unabhängige Consumer-Funktion ausführen. | Der Providerfehler bleibt isoliert; führende Fachdaten und unabhängige Consumer-Funktionen bleiben erhalten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+
+## E. Organigrammexport und Datenschutz
+
+| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung |
+|---|---|---|---|---|---|
+| E1 | Draw.io, PNG und PDF | Den sichtbaren synthetischen Organigrammstand in alle drei Formate exportieren und lokal öffnen. | Draw.io bleibt editierbar, PNG lesbar und PDF vektorbasiert; lange Inhalte bleiben innerhalb der Karten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| E2 | Mitglieder standardmäßig aus | Export ohne aktivierte Mitgliederauswahl prüfen. | Zugeordnete Konten erscheinen nicht im Export. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| E3 | Bewusste Mitgliederauswahl | Mitgliederausgabe ausdrücklich aktivieren und erneut exportieren. | Nur die aktuell sichtbaren synthetischen Zuordnungen werden aufgenommen; die Auswahl erweitert keine Rechte. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| E4 | Lokaler Exportweg | Während des Exports Browsernetzwerk und Nextcloud-Dateien prüfen. | Die Erzeugung erfolgt clientseitig als Download; es gibt keine Serverablage und keinen externen Exportdienst. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+| E5 | Datensparsame Abnahme | Formular, Screenshots und Exporte prüfen. | Keine Secrets, realen Mitgliederlisten oder unnötigen technischen Kennungen wurden dokumentiert. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | |
+
+## Wiederherstellung
+
+| Feld | Eintrag |
+|---|---|
+| Wiederhergestellter Kalenderkontext | |
+| Wiederhergestellte Organisationsdefinition | |
+| Wiederhergestellte Peer-Freigaben | |
+| Verbliebene synthetische Testdaten | |
+
+## 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 | |
diff --git a/js/api/api-client.js b/js/api/api-client.js
index bfd3ce1..55e3ceb 100644
--- a/js/api/api-client.js
+++ b/js/api/api-client.js
@@ -6,7 +6,11 @@
this.fetcher = options.fetcher || window.fetch.bind(window);
this.generateUrl = options.generateUrl || ((path) => OC.generateUrl(path));
this.requestToken = options.requestToken || (() => OC.requestToken);
- this.errorMessage = options.errorMessage || ((data, status) => data && data.message ? data.message : `HTTP ${status}`);
+ this.errorMessage = options.errorMessage || ((data, status) => {
+ const message = typeof data?.message === 'string' ? data.message.trim() : '';
+ const error = typeof data?.error === 'string' ? data.error.trim() : '';
+ return message || error || `HTTP ${status}`;
+ });
}
async request(path, options = {}) {
diff --git a/lib/Calendar/AbsenceEmployeeDiscoveryEvent.php b/lib/Calendar/AbsenceEmployeeDiscoveryEvent.php
new file mode 100644
index 0000000..febcb88
--- /dev/null
+++ b/lib/Calendar/AbsenceEmployeeDiscoveryEvent.php
@@ -0,0 +1,49 @@
+ */
+ private array $employees = [];
+
+ public function __construct(private DateTimeImmutable $start, private DateTimeImmutable $end) {
+ parent::__construct();
+ if ($start >= $end) {
+ throw new InvalidArgumentException('Ungültiger Discovery-Zeitraum.');
+ }
+ }
+
+ public function start(): DateTimeImmutable { return $this->start; }
+ public function end(): DateTimeImmutable { return $this->end; }
+
+ /** @param list $employeeUids */
+ public function provide(array $employeeUids): void {
+ foreach ($employeeUids as $employeeUid) {
+ if (!is_string($employeeUid)) {
+ continue;
+ }
+ $employeeUid = trim($employeeUid);
+ if ($employeeUid !== '') {
+ $this->employees['uid:' . $employeeUid] = $employeeUid;
+ }
+ }
+ }
+
+ /** @return list */
+ public function employeeUids(): array {
+ $employeeUids = array_values($this->employees);
+ sort($employeeUids, SORT_STRING);
+ return $employeeUids;
+ }
+}
diff --git a/lib/Catalog/AdProductCatalog.php b/lib/Catalog/AdProductCatalog.php
new file mode 100644
index 0000000..6f4a2e8
--- /dev/null
+++ b/lib/Catalog/AdProductCatalog.php
@@ -0,0 +1,204 @@
+>}|null */
+ private ?array $catalog = null;
+
+ public function __construct(private ?string $catalogFile = null) {
+ }
+
+ public function version(): int {
+ return $this->definition()['version'];
+ }
+
+ /** @return list> */
+ public function entries(): array {
+ return $this->definition()['entries'];
+ }
+
+ /** @return list> */
+ public function products(): array {
+ $products = array_values(array_filter(
+ $this->entries(),
+ static fn(array $entry): bool => $entry['kind'] === 'product',
+ ));
+ usort(
+ $products,
+ static fn(array $left, array $right): int => [$left['order'], $left['id']] <=> [$right['order'], $right['id']],
+ );
+ return $products;
+ }
+
+ /** @return list> */
+ public function menuProducts(string $suite): array {
+ return array_values(array_filter(
+ $this->products(),
+ static fn(array $entry): bool => $entry['suite'] === $suite && $entry['menu'] === true,
+ ));
+ }
+
+ /** @return array */
+ public function product(string $appId): array {
+ foreach ($this->products() as $entry) {
+ if ($entry['id'] === $appId) {
+ return $entry;
+ }
+ }
+
+ throw new InvalidArgumentException("Unbekanntes AD-Produkt: {$appId}");
+ }
+
+ /** @return list */
+ public function fullSuiteAppIds(): array {
+ return [
+ ...array_column($this->infrastructureFor('fullSuiteBundle'), 'id'),
+ ...array_column(
+ array_filter($this->products(), static fn(array $entry): bool => $entry['fullSuiteBundle'] === true),
+ 'id',
+ ),
+ ];
+ }
+
+ /** @return list */
+ public function productBundleAppIds(string $productId): array {
+ $product = $this->product($productId);
+ if ($product['productBundle'] !== true) {
+ throw new InvalidArgumentException("Produkt besitzt kein Einzelbundle: {$productId}");
+ }
+
+ $apps = array_column($this->infrastructureFor('productBundle'), 'id');
+ $apps[] = $productId;
+ return $apps;
+ }
+
+ /**
+ * @param array $catalog
+ * @return array{version: int, entries: list>}
+ */
+ public static function validate(array $catalog): array {
+ if (($catalog['version'] ?? null) !== self::VERSION || !is_array($catalog['entries'] ?? null)) {
+ throw new UnexpectedValueException('Unbekannte Katalogversion oder fehlende Einträge.');
+ }
+
+ $required = [
+ 'id', 'kind', 'suite', 'order', 'route', 'productLabel', 'navigationLabel',
+ 'standalone', 'menu', 'fullSuiteBundle', 'productBundle',
+ ];
+ $seenIds = [];
+ $seenMenuOrders = [];
+ $entries = [];
+
+ foreach ($catalog['entries'] as $entry) {
+ if (!is_array($entry) || array_diff($required, array_keys($entry)) !== []) {
+ throw new UnexpectedValueException('Katalogeintrag ist unvollständig.');
+ }
+ $id = $entry['id'];
+ if (!is_string($id) || preg_match('/^[a-z][a-z0-9_]*$/', $id) !== 1 || isset($seenIds[$id])) {
+ throw new UnexpectedValueException('Katalog-ID ist ungültig oder doppelt.');
+ }
+ $seenIds[$id] = true;
+
+ foreach (['standalone', 'menu', 'fullSuiteBundle', 'productBundle'] as $flag) {
+ if (!is_bool($entry[$flag])) {
+ throw new UnexpectedValueException("Katalogflag {$flag} ist nicht boolesch.");
+ }
+ }
+
+ if ($entry['kind'] === 'infrastructure') {
+ if ($entry['suite'] !== null || $entry['order'] !== null || $entry['route'] !== null
+ || $entry['productLabel'] !== null || $entry['navigationLabel'] !== null || $entry['standalone'] || $entry['menu']) {
+ throw new UnexpectedValueException('Infrastruktur darf keine Fachnavigation definieren.');
+ }
+ } elseif ($entry['kind'] === 'product') {
+ if (!is_string($entry['suite']) || $entry['suite'] === ''
+ || !is_int($entry['order']) || $entry['order'] < 0
+ || !is_string($entry['route']) || !str_starts_with($entry['route'], $id . '.')
+ || !is_string($entry['productLabel']) || $entry['productLabel'] === ''
+ || !is_string($entry['navigationLabel']) || $entry['navigationLabel'] === '') {
+ throw new UnexpectedValueException("Fachprodukt {$id} besitzt ungültige Navigationsdaten.");
+ }
+ if ($entry['menu']) {
+ $orderKey = $entry['suite'] . ':' . $entry['order'];
+ if (isset($seenMenuOrders[$orderKey])) {
+ throw new UnexpectedValueException('Menüreihenfolge ist nicht eindeutig.');
+ }
+ $seenMenuOrders[$orderKey] = true;
+ }
+ } else {
+ throw new UnexpectedValueException("Unbekannter Produkttyp für {$id}.");
+ }
+
+ $entries[] = $entry;
+ }
+
+ if (!isset($seenIds['localbase'], $seenIds['orgsuite'])) {
+ throw new UnexpectedValueException('Verbindliche Suite-Infrastruktur fehlt.');
+ }
+ foreach (self::REQUIRED_INFRASTRUCTURE as $infrastructureId) {
+ $matching = array_values(array_filter(
+ $entries,
+ static fn(array $entry): bool => $entry['id'] === $infrastructureId,
+ ));
+ if (($matching[0]['kind'] ?? null) !== 'infrastructure') {
+ throw new UnexpectedValueException("Verbindliche Infrastruktur ist falsch klassifiziert: {$infrastructureId}");
+ }
+ }
+
+ return ['version' => self::VERSION, 'entries' => $entries];
+ }
+
+ /**
+ * @param 'fullSuiteBundle'|'productBundle' $bundleFlag
+ * @return list>
+ */
+ private function infrastructureFor(string $bundleFlag): array {
+ $infrastructure = array_values(array_filter(
+ $this->entries(),
+ static fn(array $entry): bool => $entry['kind'] === 'infrastructure' && $entry[$bundleFlag] === true,
+ ));
+ $priority = array_flip(self::REQUIRED_INFRASTRUCTURE);
+ usort($infrastructure, static function (array $left, array $right) use ($priority): int {
+ return [$priority[$left['id']] ?? PHP_INT_MAX, $left['id']]
+ <=> [$priority[$right['id']] ?? PHP_INT_MAX, $right['id']];
+ });
+ return $infrastructure;
+ }
+
+ /** @return array{version: int, entries: list>} */
+ private function definition(): array {
+ if ($this->catalog !== null) {
+ return $this->catalog;
+ }
+
+ $file = $this->catalogFile ?? dirname(__DIR__, 2) . '/resources/ad-product-catalog.json';
+ $contents = @file_get_contents($file);
+ if ($contents === false) {
+ throw new UnexpectedValueException('AD-Produktkatalog ist nicht verfügbar.');
+ }
+ try {
+ $decoded = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
+ } catch (JsonException $error) {
+ throw new UnexpectedValueException('AD-Produktkatalog enthält ungültiges JSON.', previous: $error);
+ }
+ if (!is_array($decoded)) {
+ throw new UnexpectedValueException('AD-Produktkatalog besitzt kein Objekt als Wurzel.');
+ }
+
+ return $this->catalog = self::validate($decoded);
+ }
+}
diff --git a/lib/Organization/AdOrganizationDefinition.php b/lib/Organization/AdOrganizationDefinition.php
index 5637612..8526eb8 100644
--- a/lib/Organization/AdOrganizationDefinition.php
+++ b/lib/Organization/AdOrganizationDefinition.php
@@ -27,7 +27,7 @@ public static function get_all(array $items): array {
public static function defaults(): self {
return self::get([
- 'version' => 2,
+ 'version' => 3,
'teamGroupPrefix' => 'ad-ASN-',
'teamLabelPrefix' => 'Assistenzteam',
'teamCodeMaxLength' => 16,
@@ -40,7 +40,8 @@ public static function defaults(): self {
'gf_digi' => self::role('ad-GF-Digi', 'Geschäftsführung Digitales und Finanzen', 50, staffBlock: true, singleOccupant: true),
'assistant_gf_digi' => self::role('ad-AsdGF-Digi', 'Assistenz der Geschäftsführung Digitalisierung', 60, staffBlock: true, singleOccupant: true),
'finance_lead' => self::role('ad-Leitung-Finanzen-Lohn', 'Leitung Finanzen und Lohn', 70, staffBlock: true, singleOccupant: true),
- 'finance' => self::role('ad-Finanzen-Lohn', 'Finanzen und Lohn', 80, peerEnabled: true, staffBlock: true),
+ 'finance' => self::role('ad-Finanzen', 'Finanzen', 80, peerEnabled: true, staffBlock: true),
+ 'payroll' => self::role('ad-Lohn', 'Lohn', 85, peerEnabled: true, staffBlock: true),
'it' => self::role('ad-IT', 'IT', 90, peerEnabled: true, staffBlock: true),
'fleet_management' => self::role('ad-Fahrzeugverwaltung', 'Fahrzeugverwaltung', 95),
'secretariat' => self::role('ad-Sekretariat', 'Sekretariat', 100, peerEnabled: true, staffBlock: true),
@@ -62,7 +63,7 @@ public static function defaults(): self {
'gf_as' => ['pdl', 'bl', 'staff_hr', 'staff_qmb', 'secretariat'],
'gf_digi' => ['assistant_gf_digi', 'finance_lead', 'fleet_management', 'secretariat'],
'assistant_gf_digi' => ['it'],
- 'finance_lead' => ['finance'],
+ 'finance_lead' => ['finance', 'payroll'],
'pdl' => ['deputy_pdl', 'care_office', 'pfk'],
'deputy_pdl' => ['care_office', 'pfk'],
'secretariat' => ['reception'],
@@ -76,7 +77,7 @@ public static function defaults(): self {
['id' => 'office-south', 'label' => 'Büro Süd', 'roles' => ['office', 'bl', 'deputy_bl'], 'areas' => ['south'], 'sortOrder' => 30],
['id' => 'eb', 'label' => 'Einsatzbegleitungen', 'roles' => ['eb'], 'areas' => [], 'sortOrder' => 40],
['id' => 'pfk', 'label' => 'Pflegefachkräfte', 'roles' => ['deputy_pdl', 'care_office', 'pfk'], 'areas' => [], 'sortOrder' => 50],
- ['id' => 'staff', 'label' => 'Geschäftsführung, Leitungen und Stabsstellen', 'roles' => ['gf_as', 'pdl', 'staff_hr', 'staff_qmb', 'gf_digi', 'assistant_gf_digi', 'finance_lead', 'finance', 'it', 'secretariat'], 'areas' => [], 'sortOrder' => 60],
+ ['id' => 'staff', 'label' => 'Geschäftsführung, Leitungen und Stabsstellen', 'roles' => ['gf_as', 'pdl', 'staff_hr', 'staff_qmb', 'gf_digi', 'assistant_gf_digi', 'finance_lead', 'finance', 'payroll', 'it', 'secretariat'], 'areas' => [], 'sortOrder' => 60],
['id' => 'fleet-management', 'label' => 'Fahrzeugverwaltung', 'roles' => ['fleet_management'], 'areas' => [], 'sortOrder' => 70],
['id' => 'reception', 'label' => 'Empfang', 'roles' => ['reception'], 'areas' => [], 'sortOrder' => 80],
],
@@ -127,7 +128,12 @@ public function roleKeysForGroups(array $groupIds): array {
$lookup = array_fill_keys(array_map('strval', $groupIds), true);
$result = [];
foreach ($this->roles() as $key => $role) if (isset($lookup[$role['groupId']])) $result[] = $key;
- return $result;
+ if (isset($lookup['ad-Finanzen-Lohn'])
+ && $this->roleKeyForExactGroup('ad-Finanzen-Lohn') === null
+ && isset($this->roles()['finance'])) {
+ $result[] = 'finance';
+ }
+ return array_values(array_unique($result));
}
/** @return list */
@@ -161,6 +167,12 @@ public function roleIsAreaScopedByGroup(string $groupId): bool {
public function roleManagementIsAreaScoped(string $key): bool { return (bool)($this->roles()[$key]['managementAreaScoped'] ?? false); }
public function roleKeyForGroup(string $groupId): ?string {
+ $exact = $this->roleKeyForExactGroup($groupId);
+ if ($exact !== null) return $exact;
+ return $groupId === 'ad-Finanzen-Lohn' && isset($this->roles()['finance']) ? 'finance' : null;
+ }
+
+ private function roleKeyForExactGroup(string $groupId): ?string {
foreach ($this->roles() as $key => $role) if ($role['groupId'] === $groupId) return $key;
return null;
}
@@ -182,35 +194,50 @@ private static function role(string $groupId, string $label, int $sortOrder, boo
return compact('groupId', 'label', 'sortOrder', 'areaScoped', 'managementAreaScoped', 'peerEnabled', 'staffBlock', 'singleOccupant') + ['calendarVisible' => true];
}
- /**
- * Zweck: Ergänzt den freigegebenen Organisationsvertrag v1 additiv, ohne vorhandene Fachwerte umzuschreiben.
- * Vertrag: Kollisionen und durch neue Kanten entstehende Zyklen werden anschließend von validate() abgelehnt.
- */
+ /** Ergänzt veröffentlichte Organisationsversionen ausschließlich additiv. */
private static function migrate(array $data): array {
$version = (int)($data['version'] ?? 1);
- if ($version === 2) return $data;
- if ($version !== 1) throw new InvalidArgumentException("Organisationsversion {$version} wird nicht unterstützt.");
+ if ($version === 3) return $data;
+ if (!in_array($version, [1, 2], true)) throw new InvalidArgumentException("Organisationsversion {$version} wird nicht unterstützt.");
if (!isset($data['roles'], $data['hierarchy'], $data['organizationTeams']) || !is_array($data['roles']) || !is_array($data['hierarchy']) || !is_array($data['organizationTeams'])) return $data;
- foreach (self::versionTwoRoles() as $key => $role) if (!isset($data['roles'][$key])) $data['roles'][$key] = $role;
- foreach ([
- 'pdl' => ['deputy_pdl', 'care_office', 'pfk'],
- 'deputy_pdl' => ['care_office', 'pfk'],
- 'gf_digi' => ['fleet_management'],
- 'secretariat' => ['reception'],
- ] as $manager => $targets) {
- if (!isset($data['hierarchy'][$manager])) $data['hierarchy'][$manager] = [];
- if (!is_array($data['hierarchy'][$manager])) continue;
- foreach ($targets as $target) if (!in_array($target, $data['hierarchy'][$manager], true)) $data['hierarchy'][$manager][] = $target;
+ if ($version === 1) {
+ foreach (self::versionTwoRoles() as $key => $role) if (!isset($data['roles'][$key])) $data['roles'][$key] = $role;
+ self::appendHierarchyTargets($data['hierarchy'], [
+ 'pdl' => ['deputy_pdl', 'care_office', 'pfk'],
+ 'deputy_pdl' => ['care_office', 'pfk'],
+ 'gf_digi' => ['fleet_management'],
+ 'secretariat' => ['reception'],
+ ]);
+
+ self::migrateOrganizationTeam($data['organizationTeams'], 'pfk', 'Pflegefachkräfte', ['deputy_pdl', 'care_office', 'pfk'], 50, true);
+ self::migrateOrganizationTeam($data['organizationTeams'], 'fleet-management', 'Fahrzeugverwaltung', ['fleet_management'], 70);
+ self::migrateOrganizationTeam($data['organizationTeams'], 'reception', 'Empfang', ['reception'], 80);
+ $version = 2;
+ }
+
+ if ($version === 2) {
+ if (($data['roles']['finance']['label'] ?? null) === 'Finanzen und Lohn') {
+ $data['roles']['finance']['label'] = 'Finanzen';
+ }
+ if (!isset($data['roles']['payroll'])) $data['roles']['payroll'] = self::role('ad-Lohn', 'Lohn', 85, peerEnabled: true, staffBlock: true);
+ self::appendHierarchyTargets($data['hierarchy'], ['finance_lead' => ['finance', 'payroll']]);
+ self::migrateOrganizationTeam($data['organizationTeams'], 'staff', 'Geschäftsführung, Leitungen und Stabsstellen', ['payroll'], 60);
+ $version = 3;
}
- self::migrateOrganizationTeam($data['organizationTeams'], 'pfk', 'Pflegefachkräfte', ['deputy_pdl', 'care_office', 'pfk'], 50, true);
- self::migrateOrganizationTeam($data['organizationTeams'], 'fleet-management', 'Fahrzeugverwaltung', ['fleet_management'], 70);
- self::migrateOrganizationTeam($data['organizationTeams'], 'reception', 'Empfang', ['reception'], 80);
- $data['version'] = 2;
+ $data['version'] = $version;
return $data;
}
+ private static function appendHierarchyTargets(array &$hierarchy, array $requirements): void {
+ foreach ($requirements as $manager => $targets) {
+ if (!isset($hierarchy[$manager])) $hierarchy[$manager] = [];
+ if (!is_array($hierarchy[$manager])) continue;
+ foreach ($targets as $target) if (!in_array($target, $hierarchy[$manager], true)) $hierarchy[$manager][] = $target;
+ }
+ }
+
private static function versionTwoRoles(): array {
return [
'deputy_pdl' => self::role('ad-StvPDL', 'Stellvertretende Pflegedienstleitung', 235, singleOccupant: true),
@@ -262,7 +289,7 @@ private static function validate(array $data): array {
'calendarVisible' => (bool)($role['calendarVisible'] ?? true),
];
}
- foreach (['eb', 'deputy_pdl', 'care_office', 'fleet_management', 'reception'] as $requiredRole) if (!isset($roles[$requiredRole])) throw new InvalidArgumentException("Die fachliche Rolle {$requiredRole} fehlt.");
+ foreach (['eb', 'deputy_pdl', 'care_office', 'fleet_management', 'reception', 'finance', 'payroll'] as $requiredRole) if (!isset($roles[$requiredRole])) throw new InvalidArgumentException("Die fachliche Rolle {$requiredRole} fehlt.");
$areas = [];
foreach ($data['areas'] as $key => $area) {
@@ -322,7 +349,7 @@ private static function validate(array $data): array {
}
return [
- 'version' => 2,
+ 'version' => 3,
'teamGroupPrefix' => $teamGroupPrefix,
'teamLabelPrefix' => $teamLabelPrefix,
'teamCodeMaxLength' => $teamCodeMaxLength,
diff --git a/lib/Organization/AdOrganizationSettingsService.php b/lib/Organization/AdOrganizationSettingsService.php
index 8e5cb7a..9d7b17d 100644
--- a/lib/Organization/AdOrganizationSettingsService.php
+++ b/lib/Organization/AdOrganizationSettingsService.php
@@ -18,17 +18,22 @@ final class AdOrganizationSettingsService {
public function __construct(private IAppConfig $config) {}
public function definition(): AdOrganizationDefinition {
+ return $this->state()['definition'];
+ }
+
+ /** @return array{definition: AdOrganizationDefinition, valid: bool, persisted: bool} */
+ public function state(): array {
$raw = $this->config->getValueString(Application::APP_ID, self::KEY, '');
- if ($raw === '') return AdOrganizationDefinition::defaults();
+ if ($raw === '') return ['definition' => AdOrganizationDefinition::defaults(), 'valid' => false, 'persisted' => false];
try {
$data = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
$definition = AdOrganizationDefinition::get(is_array($data) ? $data : []);
- if ((int)($data['version'] ?? 1) < 2) {
+ if ((int)($data['version'] ?? 1) < 3) {
$this->config->setValueString(Application::APP_ID, self::KEY, json_encode($definition->toArray(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
}
- return $definition;
+ return ['definition' => $definition, 'valid' => true, 'persisted' => true];
} catch (\Throwable) {
- return AdOrganizationDefinition::defaults();
+ return ['definition' => AdOrganizationDefinition::defaults(), 'valid' => false, 'persisted' => true];
}
}
diff --git a/lib/Organization/AdOrganizationSnapshot.php b/lib/Organization/AdOrganizationSnapshot.php
new file mode 100644
index 0000000..4917bbd
--- /dev/null
+++ b/lib/Organization/AdOrganizationSnapshot.php
@@ -0,0 +1,56 @@
+ $roles
+ * @param array $areas
+ */
+ public function __construct(
+ private bool $valid,
+ private int $definitionVersion,
+ private array $roles,
+ private array $areas,
+ ) {
+ if (!$valid) {
+ $this->roles = [];
+ $this->areas = [];
+ }
+ }
+
+ public function isValid(): bool { return $this->valid; }
+ public function definitionVersion(): int { return $this->definitionVersion; }
+ public function roleGroupId(string $roleKey): ?string { return $this->roles[$roleKey]['groupId'] ?? null; }
+ public function areaGroupId(string $areaKey): ?string { return $this->areas[$areaKey]['groupId'] ?? null; }
+ /** @return list */
+ public function roleKeys(): array { return array_keys($this->roles); }
+ /** @return list */
+ public function areaKeys(): array { return array_keys($this->areas); }
+
+ /** @return array{version: int, valid: bool, definitionVersion: int, checksum: string, roles: array, areas: array} */
+ public function toArray(): array {
+ $payload = [
+ 'version' => self::CONTRACT_VERSION,
+ 'valid' => $this->valid,
+ 'definitionVersion' => $this->definitionVersion,
+ 'roles' => $this->roles,
+ 'areas' => $this->areas,
+ ];
+ return ['version' => $payload['version'], 'valid' => $payload['valid'], 'definitionVersion' => $payload['definitionVersion'], 'checksum' => $this->checksum(), 'roles' => $payload['roles'], 'areas' => $payload['areas']];
+ }
+
+ public function checksum(): string {
+ return hash('sha256', json_encode([
+ 'version' => self::CONTRACT_VERSION,
+ 'valid' => $this->valid,
+ 'definitionVersion' => $this->definitionVersion,
+ 'roles' => $this->roles,
+ 'areas' => $this->areas,
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
+ }
+}
diff --git a/lib/Organization/AdOrganizationSnapshotService.php b/lib/Organization/AdOrganizationSnapshotService.php
new file mode 100644
index 0000000..d08e49a
--- /dev/null
+++ b/lib/Organization/AdOrganizationSnapshotService.php
@@ -0,0 +1,28 @@
+settings->state();
+ $definition = $state['definition'];
+ if (!$state['valid']) {
+ return new AdOrganizationSnapshot(false, (int)$definition->toArray()['version'], [], []);
+ }
+
+ $roles = [];
+ foreach ($definition->roles() as $key => $role) {
+ $roles[$key] = ['groupId' => $role['groupId'], 'label' => $role['label']];
+ }
+ $areas = [];
+ foreach ($definition->areas() as $key => $area) {
+ $areas[$key] = ['groupId' => $area['groupId'], 'label' => $area['label']];
+ }
+ return new AdOrganizationSnapshot(true, (int)$definition->toArray()['version'], $roles, $areas);
+ }
+}
diff --git a/lib/Service/AdDemoFixtureCatalog.php b/lib/Service/AdDemoFixtureCatalog.php
index 3611c76..363ff80 100644
--- a/lib/Service/AdDemoFixtureCatalog.php
+++ b/lib/Service/AdDemoFixtureCatalog.php
@@ -40,7 +40,8 @@ private static function fixtures(): array {
['uid' => 'ad-demo-pflegebuero', 'displayName' => 'Bela Krämer (Büroorganisation Pflege)', 'roles' => ['care_office'], 'areas' => []],
['uid' => 'ad-demo-asdgf-digi', 'displayName' => 'Alexis Dorn (AsdGF-Digi)', 'roles' => ['assistant_gf_digi'], 'areas' => []],
['uid' => 'ad-demo-finanzleitung', 'displayName' => 'Leonie Frank (Leitung Finanzen und Lohn)', 'roles' => ['finance_lead'], 'areas' => []],
- ['uid' => 'ad-demo-finanzen', 'displayName' => 'Finn Lohmann (Finanzen und Lohn)', 'roles' => ['finance'], 'areas' => []],
+ ['uid' => 'ad-demo-finanzen', 'displayName' => 'Finn Lohmann (Finanzen)', 'roles' => ['finance'], 'areas' => []],
+ ['uid' => 'ad-demo-lohn', 'displayName' => 'Luca Hoffmann (Lohn)', 'roles' => ['payroll'], 'areas' => []],
['uid' => 'ad-demo-it', 'displayName' => 'Imani Teich (IT)', 'roles' => ['it'], 'areas' => []],
['uid' => 'ad-demo-fahrzeugverwaltung', 'displayName' => 'Fatima Wagner (Fahrzeugverwaltung)', 'roles' => ['fleet_management'], 'areas' => []],
['uid' => 'ad-demo-sekretariat', 'displayName' => 'Samira König (Sekretariat)', 'roles' => ['secretariat'], 'areas' => []],
diff --git a/lib/Service/AdProductSuiteService.php b/lib/Service/AdProductSuiteService.php
index 887b21e..8db6dee 100644
--- a/lib/Service/AdProductSuiteService.php
+++ b/lib/Service/AdProductSuiteService.php
@@ -4,29 +4,29 @@
namespace OCA\LocalBase\Service;
+use OCA\LocalBase\Catalog\AdProductCatalog;
use OCP\App\IAppManager;
use OCP\IUser;
+use RuntimeException;
/**
* Zweck: Beschreibt die installierte AD-Produktzusammensetzung unabhängig von Navigation und Fachrechten.
* Zusammenspiel: Produktinstaller aktiviert OrgSuite ab zwei Apps; LocalBase platziert den Adminadapter bei einer Einzelapp.
*/
final class AdProductSuiteService {
- /** @var array */
- private const PRODUCTS = [
- 'adcalendar' => 'AD Kalender',
- 'adplaner' => 'Assistenzplanung',
- 'adurlaub' => 'AD Urlaub',
- 'adroom' => 'AD Raumplaner',
- ];
-
- public function __construct(private IAppManager $apps) {
+ public function __construct(private IAppManager $apps, private ?AdProductCatalog $catalog = null) {
}
/** @return list */
public function enabledProducts(?IUser $user = null): array {
+ try {
+ $products = $this->catalog()->products();
+ } catch (RuntimeException) {
+ return [];
+ }
+
return array_values(array_filter(
- array_keys(self::PRODUCTS),
+ array_column($products, 'id'),
fn(string $appId): bool => $this->apps->isEnabledForUser($appId, $user),
));
}
@@ -36,7 +36,15 @@ public function standaloneProduct(?IUser $user = null): ?string {
return $this->enabledProducts($user)[0] ?? null;
}
- public static function label(string $appId): string {
- return self::PRODUCTS[$appId] ?? $appId;
+ public function label(string $appId): string {
+ try {
+ return $this->catalog()->product($appId)['productLabel'];
+ } catch (RuntimeException) {
+ return $appId;
+ }
+ }
+
+ private function catalog(): AdProductCatalog {
+ return $this->catalog ??= new AdProductCatalog();
}
}
diff --git a/lib/Service/StandaloneAppNavigationService.php b/lib/Service/StandaloneAppNavigationService.php
index 7bf3ee4..f1a1749 100644
--- a/lib/Service/StandaloneAppNavigationService.php
+++ b/lib/Service/StandaloneAppNavigationService.php
@@ -4,10 +4,14 @@
namespace OCA\LocalBase\Service;
+use OCA\LocalBase\Catalog\AdProductCatalog;
+use InvalidArgumentException;
use OCP\App\IAppManager;
use OCP\INavigationManager;
use OCP\IURLGenerator;
use OCP\IUserSession;
+use RuntimeException;
+use UnexpectedValueException;
/**
* Zweck: Registriert den eigenen Nextcloud-App-Einstieg nur, solange die gemeinsame OrgSuite für das Konto nicht aktiv ist.
@@ -20,9 +24,23 @@ public function __construct(
private IAppManager $appManager,
private INavigationManager $navigation,
private IURLGenerator $url,
+ private ?AdProductCatalog $catalog = null,
) {
}
+ public function addCatalogProductWhenStandalone(string $appId, string $name, string $icon): void {
+ try {
+ $product = $this->catalog()->product($appId);
+ } catch (RuntimeException) {
+ return;
+ }
+ if ($product['standalone'] !== true || $product['menu'] !== true) {
+ return;
+ }
+
+ $this->addWhenStandalone($appId, $name, $product['route'], $icon, $product['order']);
+ }
+
public function addWhenStandalone(
string $appId,
string $name,
@@ -30,6 +48,16 @@ public function addWhenStandalone(
string $icon,
int $order = 80,
): void {
+ try {
+ $product = $this->catalog()->product($appId);
+ $route = $product['route'];
+ $order = $product['order'];
+ } catch (InvalidArgumentException) {
+ // Der generische Helfer bleibt für nicht katalogisierte Fachbereiche nutzbar.
+ } catch (UnexpectedValueException) {
+ return;
+ }
+
$user = $this->userSession->getUser();
if ($user === null || $this->appManager->isEnabledForUser('orgsuite', $user)) return;
@@ -43,4 +71,8 @@ public function addWhenStandalone(
'order' => $order,
]);
}
+
+ private function catalog(): AdProductCatalog {
+ return $this->catalog ??= new AdProductCatalog();
+ }
}
diff --git a/lib/Settings/StandaloneProductAdminSection.php b/lib/Settings/StandaloneProductAdminSection.php
index f291466..5cb8cd8 100644
--- a/lib/Settings/StandaloneProductAdminSection.php
+++ b/lib/Settings/StandaloneProductAdminSection.php
@@ -23,7 +23,7 @@ public function getID(): string {
}
public function getName(): string {
- return AdProductSuiteService::label($this->getID());
+ return $this->suite->label($this->getID());
}
public function getPriority(): int {
diff --git a/resources/ad-product-catalog.json b/resources/ad-product-catalog.json
new file mode 100644
index 0000000..585b050
--- /dev/null
+++ b/resources/ad-product-catalog.json
@@ -0,0 +1,96 @@
+{
+ "version": 1,
+ "entries": [
+ {
+ "id": "localbase",
+ "kind": "infrastructure",
+ "suite": null,
+ "order": null,
+ "route": null,
+ "productLabel": null,
+ "navigationLabel": null,
+ "standalone": false,
+ "menu": false,
+ "fullSuiteBundle": true,
+ "productBundle": true
+ },
+ {
+ "id": "orgsuite",
+ "kind": "infrastructure",
+ "suite": null,
+ "order": null,
+ "route": null,
+ "productLabel": null,
+ "navigationLabel": null,
+ "standalone": false,
+ "menu": false,
+ "fullSuiteBundle": true,
+ "productBundle": true
+ },
+ {
+ "id": "adcalendar",
+ "kind": "product",
+ "suite": "ad",
+ "order": 80,
+ "route": "adcalendar.page.index",
+ "productLabel": "AD Kalender",
+ "navigationLabel": "Kalender",
+ "standalone": true,
+ "menu": true,
+ "fullSuiteBundle": true,
+ "productBundle": true
+ },
+ {
+ "id": "adplaner",
+ "kind": "product",
+ "suite": "ad",
+ "order": 81,
+ "route": "adplaner.page.index",
+ "productLabel": "Assistenzplanung",
+ "navigationLabel": "Assistenzplanung",
+ "standalone": true,
+ "menu": true,
+ "fullSuiteBundle": true,
+ "productBundle": true
+ },
+ {
+ "id": "adurlaub",
+ "kind": "product",
+ "suite": "ad",
+ "order": 82,
+ "route": "adurlaub.page.index",
+ "productLabel": "AD Urlaub",
+ "navigationLabel": "Urlaub",
+ "standalone": true,
+ "menu": true,
+ "fullSuiteBundle": true,
+ "productBundle": true
+ },
+ {
+ "id": "adroom",
+ "kind": "product",
+ "suite": "ad",
+ "order": 83,
+ "route": "adroom.page.index",
+ "productLabel": "AD Raumplaner",
+ "navigationLabel": "Räume",
+ "standalone": true,
+ "menu": true,
+ "fullSuiteBundle": true,
+ "productBundle": true
+ },
+ {
+ "id": "adrecruitment",
+ "kind": "product",
+ "suite": "ad",
+ "order": 84,
+ "route": "adrecruitment.page.index",
+ "productLabel": "AD Recruitment",
+ "navigationLabel": "Recruitment",
+ "standalone": true,
+ "menu": true,
+ "fullSuiteBundle": true,
+ "productBundle": true
+ }
+ ]
+}
diff --git a/tests/Controller/AdSuiteAdminApiControllerExecutionTest.php b/tests/Controller/AdSuiteAdminApiControllerExecutionTest.php
index b7c0d2b..e0b19f5 100644
--- a/tests/Controller/AdSuiteAdminApiControllerExecutionTest.php
+++ b/tests/Controller/AdSuiteAdminApiControllerExecutionTest.php
@@ -74,7 +74,6 @@ public function status(): array { return ['compatible' => true, 'demoWritable' =
}
namespace {
- require_once __DIR__ . '/../../lib/Controller/AdSuiteAdminApiController.php';
use OCA\LocalBase\Organization\AdOrganizationSettingsService;
use OCA\LocalBase\Organization\AdSuiteAdminSettingsService;
diff --git a/tests/Controller/ApiResponderSmokeTest.php b/tests/Controller/ApiResponderSmokeTest.php
index e7e20ff..fc9025d 100644
--- a/tests/Controller/ApiResponderSmokeTest.php
+++ b/tests/Controller/ApiResponderSmokeTest.php
@@ -10,7 +10,6 @@
eval('namespace OCP\AppFramework\Http; class DataResponse { public function __construct(private mixed $data = [], private int $status = 200) {} public function getData(): mixed { return $this->data; } public function getStatus(): int { return $this->status; } }');
}
- require __DIR__ . '/../../lib/Controller/ApiResponder.php';
use OCA\LocalBase\Controller\ApiResponder;
use OCP\AppFramework\Http;
diff --git a/tests/Model/ModelApiTraitSmokeTest.php b/tests/Model/ModelApiTraitSmokeTest.php
index ce47743..15d6256 100644
--- a/tests/Model/ModelApiTraitSmokeTest.php
+++ b/tests/Model/ModelApiTraitSmokeTest.php
@@ -3,8 +3,6 @@
declare(strict_types=1);
namespace {
- require __DIR__ . '/../helpers.php';
- require __DIR__ . '/../../lib/Model/ModelApiTrait.php';
use OCA\LocalBase\Model\ModelApiTrait;
use function OCA\LocalBase\Tests\assertSameValue;
diff --git a/tests/Service/AbsenceEmployeeDiscoveryEventTest.php b/tests/Service/AbsenceEmployeeDiscoveryEventTest.php
new file mode 100644
index 0000000..6297fad
--- /dev/null
+++ b/tests/Service/AbsenceEmployeeDiscoveryEventTest.php
@@ -0,0 +1,43 @@
+start() !== $start || $event->end() !== $end || $event->employeeUids() !== []) {
+ throw new RuntimeException('Eine unbeantwortete Discovery muss begrenzt und leer bleiben.');
+}
+
+$event->provide([' bob ', 'alice', '', 'alice', "\t", '0']);
+$event->provide(['carol', 'bob']);
+if ($event->employeeUids() !== ['0', 'alice', 'bob', 'carol']) {
+ throw new RuntimeException('Provider-UIDs werden nicht normalisiert, dedupliziert und stabil sortiert.');
+}
+
+/** @phpstan-ignore-next-line Absichtlich fehlerhafte Providerdaten am öffentlichen Vertrag. */
+$event->provide([42, ['nested'], new stdClass()]);
+if ($event->employeeUids() !== ['0', 'alice', 'bob', 'carol']) {
+ throw new RuntimeException('Ungültige Providerwerte dürfen die Discovery-Menge nicht erweitern.');
+}
+
+try {
+ new AbsenceEmployeeDiscoveryEvent($end, $start);
+ throw new RuntimeException('Ein ungültiges Discovery-Intervall wurde akzeptiert.');
+} catch (InvalidArgumentException) {
+}
+
+try {
+ new AbsenceEmployeeDiscoveryEvent($start, $start);
+ throw new RuntimeException('Ein leerer Discovery-Zeitraum wurde akzeptiert.');
+} catch (InvalidArgumentException) {
+}
+
+echo "AbsenceEmployeeDiscoveryEventTest: OK\n";
diff --git a/tests/Service/AbsenceQueryEventSmokeTest.php b/tests/Service/AbsenceQueryEventSmokeTest.php
index 0413def..7c8690e 100644
--- a/tests/Service/AbsenceQueryEventSmokeTest.php
+++ b/tests/Service/AbsenceQueryEventSmokeTest.php
@@ -2,12 +2,10 @@
declare(strict_types=1);
-require_once __DIR__ . '/../../lib/Calendar/AbsenceInterval.php';
if (!class_exists(\OCP\EventDispatcher\Event::class)) {
eval('namespace OCP\\EventDispatcher; class Event { public function __construct() {} }');
}
-require_once __DIR__ . '/../../lib/Calendar/AbsenceQueryEvent.php';
use OCA\LocalBase\Calendar\AbsenceInterval;
use OCA\LocalBase\Calendar\AbsenceQueryEvent;
diff --git a/tests/Service/AdDemoFixtureCatalogSmokeTest.php b/tests/Service/AdDemoFixtureCatalogSmokeTest.php
index dfef2ad..27156ce 100644
--- a/tests/Service/AdDemoFixtureCatalogSmokeTest.php
+++ b/tests/Service/AdDemoFixtureCatalogSmokeTest.php
@@ -2,8 +2,6 @@
declare(strict_types=1);
-require_once __DIR__ . '/../../lib/Organization/AdOrganizationDefinition.php';
-require_once __DIR__ . '/../../lib/Service/AdDemoFixtureCatalog.php';
use OCA\LocalBase\Organization\AdOrganizationDefinition;
use OCA\LocalBase\Service\AdDemoFixtureCatalog;
diff --git a/tests/Service/AdOrganizationDefinitionSmokeTest.php b/tests/Service/AdOrganizationDefinitionSmokeTest.php
index 654380a..7508934 100644
--- a/tests/Service/AdOrganizationDefinitionSmokeTest.php
+++ b/tests/Service/AdOrganizationDefinitionSmokeTest.php
@@ -2,9 +2,6 @@
declare(strict_types=1);
-require_once __DIR__ . '/../../lib/Organization/AdOrganizationDefinition.php';
-require_once __DIR__ . '/../../lib/Organization/AdOrganizationHierarchy.php';
-require_once __DIR__ . '/../../lib/Organization/AdOrganizationPermissionPolicy.php';
use OCA\LocalBase\Organization\AdOrganizationDefinition;
use OCA\LocalBase\Organization\AdOrganizationHierarchy;
@@ -15,7 +12,20 @@
if ($definition->roleLabelForGroup('ad-Buero') !== 'Büromitarbeiter*innen') throw new RuntimeException('Sichtbarer Rollenname fehlt.');
if ($definition->areaLabelForGroup('ad-Bereich-Sued') !== 'Süd') throw new RuntimeException('Sichtbarer Bereichsname fehlt.');
$roles = $definition->roles();
-if ($definition->toArray()['version'] !== 2) throw new RuntimeException('Die additive Organisationsmigration verwendet nicht Vertragsversion 2.');
+if ($definition->toArray()['version'] !== 3) throw new RuntimeException('Die additive Organisationsmigration verwendet nicht Vertragsversion 3.');
+$financeGroup = $definition->roleGroupId('finance');
+$payrollGroup = $definition->roleGroupId('payroll');
+if ($financeGroup !== 'ad-Finanzen' || $payrollGroup !== 'ad-Lohn' || $financeGroup === $payrollGroup) {
+ throw new RuntimeException('Finanzen und Lohn sind nicht als getrennte semantische Rollen vorbelegt.');
+}
+if ($definition->roleKeysForGroups(['ad-Finanzen-Lohn']) !== ['finance']) {
+ throw new RuntimeException('Die bisherige kombinierte Gruppen-ID verliert ihre sichere Finanz-Kompatibilität.');
+}
+foreach (['finance', 'payroll'] as $financeRole) {
+ if (!in_array($financeRole, $definition->hierarchy()['finance_lead'] ?? [], true)) {
+ throw new RuntimeException("{$financeRole} bleibt nicht unter derselben Leitung einsortiert.");
+ }
+}
foreach ([
'deputy_pdl' => ['ad-StvPDL', 'Stellvertretende Pflegedienstleitung', true],
'care_office' => ['ad-Bueroorganisation-Pflege', 'Büroorganisation Pflege', false],
@@ -69,6 +79,30 @@
$organizationTeams = array_column($definition->organizationTeams(), null, 'id');
if (($organizationTeams['pfk']['roles'] ?? []) !== ['deputy_pdl', 'care_office', 'pfk']) throw new RuntimeException('Die Pflege-Urlaubsansicht beginnt nicht mit Stv. PDL und Büroorganisation Pflege.');
if (($organizationTeams['fleet-management']['roles'] ?? []) !== ['fleet_management'] || ($organizationTeams['reception']['roles'] ?? []) !== ['reception']) throw new RuntimeException('Urlaubsansichten für Fahrzeugverwaltung oder Empfang fehlen.');
+if (!in_array('finance', $organizationTeams['staff']['roles'] ?? [], true) || !in_array('payroll', $organizationTeams['staff']['roles'] ?? [], true)) throw new RuntimeException('Finanzen und Lohn liegen nicht gemeinsam im bisherigen Organisationsblock.');
+
+$versionTwo = $definition->toArray();
+$versionTwo['version'] = 2;
+unset($versionTwo['roles']['payroll']);
+$versionTwo['roles']['finance']['groupId'] = 'existing-finance-and-payroll';
+$versionTwo['roles']['finance']['label'] = 'Finanzen und Lohn';
+$versionTwo['hierarchy']['finance_lead'] = ['finance'];
+foreach ($versionTwo['organizationTeams'] as &$versionTwoTeam) {
+ $versionTwoTeam['roles'] = array_values(array_diff($versionTwoTeam['roles'], ['payroll']));
+}
+unset($versionTwoTeam);
+$migratedVersionTwo = AdOrganizationDefinition::get($versionTwo);
+if ($migratedVersionTwo->toArray()['version'] !== 3
+ || $migratedVersionTwo->roleGroupId('finance') !== 'existing-finance-and-payroll'
+ || $migratedVersionTwo->roleGroupId('payroll') !== 'ad-Lohn'
+ || $migratedVersionTwo->roleLabel('finance') !== 'Finanzen') {
+ throw new RuntimeException('Version 2 wird nicht additiv und mit erhaltener Bestandsgruppe migriert.');
+}
+$customVersionTwo = $versionTwo;
+$customVersionTwo['roles']['finance']['label'] = 'Individuelle Buchhaltung';
+if (AdOrganizationDefinition::get($customVersionTwo)->roleLabel('finance') !== 'Individuelle Buchhaltung') {
+ throw new RuntimeException('Eine individuelle Finanzbezeichnung wird beim Split überschrieben.');
+}
$legacyOrganization = $definition->toArray();
$legacyOrganization['version'] = 1;
@@ -82,7 +116,7 @@
foreach ($legacyOrganization['organizationTeams'] as &$team) if ($team['id'] === 'pfk') $team['roles'] = ['pfk'];
unset($team);
$migratedOrganization = AdOrganizationDefinition::get($legacyOrganization);
-if ($migratedOrganization->toArray()['version'] !== 2 || $migratedOrganization->roleLabel('pfk') !== 'Individuelle Pflegebezeichnung' || $migratedOrganization->roles()['pfk']['sortOrder'] !== 777) throw new RuntimeException('Bestehende Organisationswerte werden bei der Migration verändert.');
+if ($migratedOrganization->toArray()['version'] !== 3 || $migratedOrganization->roleLabel('pfk') !== 'Individuelle Pflegebezeichnung' || $migratedOrganization->roles()['pfk']['sortOrder'] !== 777) throw new RuntimeException('Bestehende Organisationswerte werden bei der Migration verändert.');
if (!in_array('office', $migratedOrganization->hierarchy()['pdl'], true) || !$migratedOrganization->managesRole('deputy_pdl', 'pfk')) throw new RuntimeException('Bestehende Kanten werden nicht bewahrt oder neue Hierarchiekanten fehlen nach der Migration.');
$collidingLegacy = $legacyOrganization;
diff --git a/tests/Service/AdOrganizationPermissionPolicySmokeTest.php b/tests/Service/AdOrganizationPermissionPolicySmokeTest.php
index e2e761e..06a0365 100644
--- a/tests/Service/AdOrganizationPermissionPolicySmokeTest.php
+++ b/tests/Service/AdOrganizationPermissionPolicySmokeTest.php
@@ -2,9 +2,6 @@
declare(strict_types=1);
-require_once __DIR__ . '/../../lib/Organization/AdOrganizationDefinition.php';
-require_once __DIR__ . '/../../lib/Organization/AdOrganizationHierarchy.php';
-require_once __DIR__ . '/../../lib/Organization/AdOrganizationPermissionPolicy.php';
use OCA\LocalBase\Organization\AdOrganizationHierarchy;
use OCA\LocalBase\Organization\AdOrganizationPermissionPolicy;
diff --git a/tests/Service/AdOrganizationSettingsServiceSmokeTest.php b/tests/Service/AdOrganizationSettingsServiceSmokeTest.php
index 0599bb3..ea393e1 100644
--- a/tests/Service/AdOrganizationSettingsServiceSmokeTest.php
+++ b/tests/Service/AdOrganizationSettingsServiceSmokeTest.php
@@ -12,14 +12,12 @@ public function setValueString(string $appId, string $key, string $value): void;
}
namespace OCA\LocalBase\AppInfo {
- if (!class_exists(Application::class)) {
+ if (!class_exists(Application::class, false)) {
final class Application { public const APP_ID = 'localbase'; }
}
}
namespace {
- require_once __DIR__ . '/../../lib/Organization/AdOrganizationDefinition.php';
- require_once __DIR__ . '/../../lib/Organization/AdOrganizationSettingsService.php';
$config = new class implements \OCP\IAppConfig {
public array $values = [];
@@ -42,9 +40,9 @@ public function setValueString(string $appId, string $key, string $value): void
foreach ($legacy['organizationTeams'] as &$team) if ($team['id'] === 'pfk') $team['roles'] = ['pfk'];
unset($team);
$config->values['localbase']['ad_organization_definition'] = json_encode($legacy, JSON_THROW_ON_ERROR);
- if ($service->definition()->toArray()['version'] !== 2 || !isset($service->definition()->roles()['deputy_pdl'])) throw new \RuntimeException('Gespeicherte Organisationsversion 1 wird nicht automatisch ergänzt.');
+ if ($service->definition()->toArray()['version'] !== 3 || !isset($service->definition()->roles()['deputy_pdl'], $service->definition()->roles()['payroll'])) throw new \RuntimeException('Gespeicherte Organisationsversion 1 wird nicht automatisch ergänzt.');
$persistedMigration = json_decode($config->values['localbase']['ad_organization_definition'], true, 64, JSON_THROW_ON_ERROR);
- if (($persistedMigration['version'] ?? null) !== 2 || !isset($persistedMigration['roles']['fleet_management'])) throw new \RuntimeException('Additive Organisationsmigration wird nicht idempotent persistiert.');
+ if (($persistedMigration['version'] ?? null) !== 3 || !isset($persistedMigration['roles']['fleet_management'], $persistedMigration['roles']['payroll'])) throw new \RuntimeException('Additive Organisationsmigration wird nicht idempotent persistiert.');
$config->values['localbase']['ad_organization_definition'] = '{kaputt';
if ($service->definition()->roleGroupId('eb') !== 'ad-EB') throw new \RuntimeException('Ungültige Persistenz fällt nicht sicher auf Defaults zurück.');
diff --git a/tests/Service/AdOrganizationSnapshotServiceSmokeTest.php b/tests/Service/AdOrganizationSnapshotServiceSmokeTest.php
new file mode 100644
index 0000000..60ad2b9
--- /dev/null
+++ b/tests/Service/AdOrganizationSnapshotServiceSmokeTest.php
@@ -0,0 +1,58 @@
+values[$appId][$key] ?? $default; }
+ public function setValueString(string $appId, string $key, string $value): void { $this->values[$appId][$key] = $value; }
+ };
+ $settings = new AdOrganizationSettingsService($config);
+ $snapshots = new AdOrganizationSnapshotService($settings);
+
+ $missing = $snapshots->snapshot();
+ if ($missing->isValid() || $missing->roleGroupId('staff_hr') !== null || $missing->areaKeys() !== []) {
+ throw new RuntimeException('Fehlende Organisationspersistenz erzeugt einen berechtigenden Snapshot.');
+ }
+
+ $settings->save($settings->definition()->toArray());
+ $snapshot = $snapshots->snapshot();
+ if (!$snapshot->isValid()
+ || $snapshot->definitionVersion() !== 3
+ || $snapshot->roleGroupId('finance') === $snapshot->roleGroupId('payroll')
+ || $snapshot->areaGroupId('west') === null
+ || strlen($snapshot->checksum()) !== 64) {
+ throw new RuntimeException('Der gültige Organisationssnapshot ist unvollständig.');
+ }
+ $payload = $snapshot->toArray();
+ if (isset($payload['members']) || str_contains(json_encode($payload, JSON_THROW_ON_ERROR), 'displayName')) {
+ throw new RuntimeException('Der Organisationssnapshot enthält personenbezogene Mitgliederlisten.');
+ }
+
+ $config->values['localbase']['ad_organization_definition'] = '{kaputt';
+ if ($snapshots->snapshot()->isValid()) {
+ throw new RuntimeException('Ungültige Organisationspersistenz wird als gültiger Snapshot veröffentlicht.');
+ }
+
+ echo "AdOrganizationSnapshotServiceSmokeTest: OK\n";
+}
diff --git a/tests/Service/AdProductCatalogSmokeTest.php b/tests/Service/AdProductCatalogSmokeTest.php
new file mode 100644
index 0000000..caff73e
--- /dev/null
+++ b/tests/Service/AdProductCatalogSmokeTest.php
@@ -0,0 +1,131 @@
+version() !== 1) {
+ throw new RuntimeException('Die Katalogversion ist nicht stabil auf Version 1 festgelegt.');
+}
+
+$productIds = array_column($catalog->products(), 'id');
+$expectedProducts = ['adcalendar', 'adplaner', 'adurlaub', 'adroom', 'adrecruitment'];
+if ($productIds !== $expectedProducts) {
+ throw new RuntimeException('AD-Fachprodukte oder ihre Reihenfolge weichen vom Vertrag ab.');
+}
+
+$menuIds = array_column($catalog->menuProducts('ad'), 'id');
+if ($menuIds !== $expectedProducts) {
+ throw new RuntimeException('Das AD-Menü wird nicht vollständig aus dem Katalog abgeleitet.');
+}
+
+$fullSuite = $catalog->fullSuiteAppIds();
+if ($fullSuite !== ['localbase', 'orgsuite', ...$expectedProducts]) {
+ throw new RuntimeException('Das vollständige Suite-Bundle enthält nicht Infrastruktur und alle AD-Produkte.');
+}
+
+if ($catalog->productBundleAppIds('adrecruitment') !== ['localbase', 'orgsuite', 'adrecruitment']) {
+ throw new RuntimeException('Das Recruitment-Produktbundle ist nicht eigenständig zusammengesetzt.');
+}
+
+$recruitment = $catalog->product('adrecruitment');
+if ($recruitment['route'] !== 'adrecruitment.page.index'
+ || $recruitment['standalone'] !== true
+ || $recruitment['menu'] !== true
+ || $recruitment['fullSuiteBundle'] !== true
+ || $recruitment['productBundle'] !== true) {
+ throw new RuntimeException('Der Recruitment-Katalogeintrag erfüllt den Suite-Vertrag nicht.');
+}
+
+try {
+ $catalog->product('unknown-product');
+ throw new RuntimeException('Ein unbekanntes Produkt wurde akzeptiert.');
+} catch (InvalidArgumentException) {
+}
+
+$invalid = json_decode((string)file_get_contents($catalogFile), true, flags: JSON_THROW_ON_ERROR);
+$invalid['entries'][] = $invalid['entries'][2];
+try {
+ AdProductCatalog::validate($invalid);
+ throw new RuntimeException('Eine doppelte Produkt-ID wurde akzeptiert.');
+} catch (UnexpectedValueException) {
+}
+
+$invalid = json_decode((string)file_get_contents($catalogFile), true, flags: JSON_THROW_ON_ERROR);
+$invalid['entries'][2]['route'] = 'foreign.page.index';
+try {
+ AdProductCatalog::validate($invalid);
+ throw new RuntimeException('Eine fremde technische Einstiegsroute wurde akzeptiert.');
+} catch (UnexpectedValueException) {
+}
+
+$definition = json_decode((string)file_get_contents($catalogFile), true, flags: JSON_THROW_ON_ERROR);
+$assertInvalid = static function (array $candidate, string $message): void {
+ try {
+ AdProductCatalog::validate($candidate);
+ throw new RuntimeException($message);
+ } catch (UnexpectedValueException) {
+ }
+};
+$candidate = $definition; $candidate['version'] = 99;
+$assertInvalid($candidate, 'Eine unbekannte Katalogversion wurde akzeptiert.');
+$candidate = $definition; unset($candidate['entries'][2]['route']);
+$assertInvalid($candidate, 'Ein unvollständiger Katalogeintrag wurde akzeptiert.');
+$candidate = $definition; $candidate['entries'][2]['menu'] = 'yes';
+$assertInvalid($candidate, 'Ein nicht-boolesches Katalogflag wurde akzeptiert.');
+$candidate = $definition; $candidate['entries'][0]['route'] = 'localbase.page.index';
+$assertInvalid($candidate, 'Infrastruktur mit Fachnavigation wurde akzeptiert.');
+$candidate = $definition; $candidate['entries'][3]['order'] = $candidate['entries'][2]['order'];
+$assertInvalid($candidate, 'Eine doppelte Menüreihenfolge wurde akzeptiert.');
+$candidate = $definition; $candidate['entries'][2]['kind'] = 'unknown';
+$assertInvalid($candidate, 'Ein unbekannter Produkttyp wurde akzeptiert.');
+$candidate = $definition; array_splice($candidate['entries'], 1, 1);
+$assertInvalid($candidate, 'Fehlende verbindliche Infrastruktur wurde akzeptiert.');
+$candidate = $definition;
+$candidate['entries'][0] = array_replace($candidate['entries'][0], [
+ 'kind' => 'product', 'suite' => 'ad', 'order' => 999, 'route' => 'localbase.page.index',
+ 'productLabel' => 'LocalBase', 'navigationLabel' => 'LocalBase',
+]);
+$assertInvalid($candidate, 'Falsch klassifizierte verbindliche Infrastruktur wurde akzeptiert.');
+
+$assertUnreadableCatalog = static function (string $contents, string $message): void {
+ $file = tempnam(sys_get_temp_dir(), 'ad-product-catalog-invalid-');
+ if ($file === false) throw new RuntimeException('Temporärer Fehlerkatalog konnte nicht angelegt werden.');
+ try {
+ file_put_contents($file, $contents);
+ (new AdProductCatalog($file))->entries();
+ throw new RuntimeException($message);
+ } catch (UnexpectedValueException) {
+ } finally {
+ @unlink($file);
+ }
+};
+$assertUnreadableCatalog('{', 'Ungültiges Katalog-JSON wurde akzeptiert.');
+$assertUnreadableCatalog('[]', 'Eine Katalogliste wurde als Objektwurzel akzeptiert.');
+
+$reordered = json_decode((string)file_get_contents($catalogFile), true, flags: JSON_THROW_ON_ERROR);
+$reordered['entries'] = array_reverse($reordered['entries']);
+$temporaryCatalog = tempnam(sys_get_temp_dir(), 'ad-product-catalog-');
+if ($temporaryCatalog === false) {
+ throw new RuntimeException('Temporärer Katalog konnte nicht angelegt werden.');
+}
+try {
+ file_put_contents($temporaryCatalog, json_encode($reordered, JSON_THROW_ON_ERROR));
+ $reorderedCatalog = new AdProductCatalog($temporaryCatalog);
+ if (array_column($reorderedCatalog->products(), 'id') !== $expectedProducts
+ || $reorderedCatalog->fullSuiteAppIds() !== ['localbase', 'orgsuite', ...$expectedProducts]) {
+ throw new RuntimeException('Katalogreihenfolge hängt von der JSON-Dateireihenfolge statt vom Vertrag ab.');
+ }
+} finally {
+ @unlink($temporaryCatalog);
+}
+
+echo "AdProductCatalogSmokeTest: OK\n";
diff --git a/tests/Service/AdProductSuiteServiceSmokeTest.php b/tests/Service/AdProductSuiteServiceSmokeTest.php
index da41d42..6b2d724 100644
--- a/tests/Service/AdProductSuiteServiceSmokeTest.php
+++ b/tests/Service/AdProductSuiteServiceSmokeTest.php
@@ -6,8 +6,8 @@
namespace OCP\App { interface IAppManager { public function isEnabledForUser($appId, $user = null); } }
namespace {
- require_once __DIR__ . '/../../lib/Service/AdProductSuiteService.php';
+ use OCA\LocalBase\Catalog\AdProductCatalog;
use OCA\LocalBase\Service\AdProductSuiteService;
use OCP\App\IAppManager;
@@ -15,17 +15,23 @@
public array $enabled = ['adcalendar'];
public function isEnabledForUser($appId, $user = null): bool { return in_array($appId, $this->enabled, true); }
};
- $service = new AdProductSuiteService($apps);
+ $service = new AdProductSuiteService($apps, new AdProductCatalog());
if ($service->enabledProducts() !== ['adcalendar']) throw new RuntimeException('Einzelprodukt wird nicht erkannt.');
if ($service->standaloneProduct() !== 'adcalendar') throw new RuntimeException('Standalone-Ziel fehlt.');
- $apps->enabled = ['adcalendar', 'adroom', 'orgsuite'];
- if ($service->enabledProducts() !== ['adcalendar', 'adroom']) throw new RuntimeException('Produktreihenfolge ist nicht stabil.');
+ $apps->enabled = ['adcalendar', 'adroom', 'adrecruitment', 'orgsuite'];
+ if ($service->enabledProducts() !== ['adcalendar', 'adroom', 'adrecruitment']) throw new RuntimeException('Produktreihenfolge ist nicht stabil.');
if ($service->standaloneProduct() !== null) throw new RuntimeException('Bei aktiver OrgSuite darf kein Standalone-Adminziel bestehen.');
$apps->enabled = [];
if ($service->standaloneProduct() !== null) throw new RuntimeException('Ohne Fachapp darf kein Adminziel bestehen.');
- if (AdProductSuiteService::label('adurlaub') !== 'AD Urlaub') throw new RuntimeException('Produktname fehlt.');
+ if ($service->label('adrecruitment') !== 'AD Recruitment') throw new RuntimeException('Produktname stammt nicht aus dem Katalog.');
+
+ $missingCatalog = new AdProductCatalog(__DIR__ . '/missing-catalog.json');
+ $brokenService = new AdProductSuiteService($apps, $missingCatalog);
+ if ($brokenService->enabledProducts() !== [] || $brokenService->label('adcalendar') !== 'adcalendar') {
+ throw new RuntimeException('Fehlender Katalog erweitert Produkte oder verliert den sicheren Label-Fallback.');
+ }
echo "AdProductSuiteServiceSmokeTest: OK\n";
}
diff --git a/tests/Service/AdSuiteAdminLayoutServiceSmokeTest.php b/tests/Service/AdSuiteAdminLayoutServiceSmokeTest.php
index e63611e..4abfc29 100644
--- a/tests/Service/AdSuiteAdminLayoutServiceSmokeTest.php
+++ b/tests/Service/AdSuiteAdminLayoutServiceSmokeTest.php
@@ -13,7 +13,6 @@ public function setValueArray(string $userId, string $app, string $key, array $v
namespace Psr\Log { interface LoggerInterface { public function warning(string $message, array $context = []): void; } }
namespace {
- require_once __DIR__ . '/../../lib/Service/AdSuiteAdminLayoutService.php';
use OCA\LocalBase\Service\AdSuiteAdminLayoutService;
use OCP\Config\IUserConfig;
diff --git a/tests/Service/AdSuiteAdminSettingsServiceSmokeTest.php b/tests/Service/AdSuiteAdminSettingsServiceSmokeTest.php
index 027914d..7dd9aa7 100644
--- a/tests/Service/AdSuiteAdminSettingsServiceSmokeTest.php
+++ b/tests/Service/AdSuiteAdminSettingsServiceSmokeTest.php
@@ -12,15 +12,12 @@ public function setValueString(string $appId, string $key, string $value): void;
}
namespace OCA\LocalBase\AppInfo {
- if (!class_exists(Application::class)) {
+ if (!class_exists(Application::class, false)) {
final class Application { public const APP_ID = 'localbase'; }
}
}
namespace {
- require_once __DIR__ . '/../../lib/Organization/AdOrganizationDefinition.php';
- require_once __DIR__ . '/../../lib/Organization/AdOrganizationSettingsService.php';
- require_once __DIR__ . '/../../lib/Organization/AdSuiteAdminSettingsService.php';
$config = new class implements \OCP\IAppConfig {
public array $values = [];
diff --git a/tests/Service/AppLoggerSmokeTest.php b/tests/Service/AppLoggerSmokeTest.php
index 58162c1..ecf8f77 100644
--- a/tests/Service/AppLoggerSmokeTest.php
+++ b/tests/Service/AppLoggerSmokeTest.php
@@ -10,7 +10,6 @@
eval('namespace Psr\Log; interface LoggerInterface { public function emergency($message, array $context = array()); public function alert($message, array $context = array()); public function critical($message, array $context = array()); public function error($message, array $context = array()); public function warning($message, array $context = array()); public function notice($message, array $context = array()); public function info($message, array $context = array()); public function debug($message, array $context = array()); public function log($level, $message, array $context = array()); }');
}
- require __DIR__ . '/../../lib/Service/AppLogger.php';
use OCA\LocalBase\Service\AppLogger;
use OCP\IUserSession;
diff --git a/tests/Service/ApplicationBootstrapExecutionTest.php b/tests/Service/ApplicationBootstrapExecutionTest.php
index 1e39918..ad00925 100644
--- a/tests/Service/ApplicationBootstrapExecutionTest.php
+++ b/tests/Service/ApplicationBootstrapExecutionTest.php
@@ -23,8 +23,6 @@ public function registerSetting(string $type, string $setting);
}
namespace {
- require_once __DIR__ . '/../../lib/Service/AdProductSuiteService.php';
- require_once __DIR__ . '/../../lib/AppInfo/Application.php';
use OCA\LocalBase\AppInfo\Application;
use OCA\LocalBase\Service\AdProductSuiteService;
diff --git a/tests/Service/CalendarContextSettingsServiceSmokeTest.php b/tests/Service/CalendarContextSettingsServiceSmokeTest.php
index c9f3a5b..3aaa358 100644
--- a/tests/Service/CalendarContextSettingsServiceSmokeTest.php
+++ b/tests/Service/CalendarContextSettingsServiceSmokeTest.php
@@ -12,14 +12,12 @@ public function setValueString(string $appId, string $key, string $value): void;
}
namespace OCA\LocalBase\AppInfo {
- if (!class_exists(Application::class)) {
+ if (!class_exists(Application::class, false)) {
final class Application { public const APP_ID = 'localbase'; }
}
}
namespace {
- require_once __DIR__ . '/../../lib/Calendar/CalendarContext.php';
- require_once __DIR__ . '/../../lib/Calendar/CalendarContextSettingsService.php';
use OCA\LocalBase\Calendar\CalendarContext;
use OCA\LocalBase\Calendar\CalendarContextSettingsService;
diff --git a/tests/Service/DemoAccountProvisioningServiceSmokeTest.php b/tests/Service/DemoAccountProvisioningServiceSmokeTest.php
index 35102a7..e8f99c9 100644
--- a/tests/Service/DemoAccountProvisioningServiceSmokeTest.php
+++ b/tests/Service/DemoAccountProvisioningServiceSmokeTest.php
@@ -29,7 +29,6 @@ public function createGroup(string $gid): ?IGroup;
}
namespace {
- require_once __DIR__ . '/../../lib/Service/DemoAccountProvisioningService.php';
use OCA\LocalBase\Service\DemoAccountProvisioningService;
use OCP\IAppConfig;
diff --git a/tests/Service/GroupProvisioningServiceSmokeTest.php b/tests/Service/GroupProvisioningServiceSmokeTest.php
index 163f2a3..2e52547 100644
--- a/tests/Service/GroupProvisioningServiceSmokeTest.php
+++ b/tests/Service/GroupProvisioningServiceSmokeTest.php
@@ -7,8 +7,6 @@
eval('namespace OCP; interface IGroupManager { public function groupExists($gid); public function createGroup($gid); }');
}
- require __DIR__ . '/../helpers.php';
- require __DIR__ . '/../../lib/Service/GroupProvisioningService.php';
use OCA\LocalBase\Service\GroupProvisioningService;
use OCP\IGroupManager;
diff --git a/tests/Service/HolidayCalendarServiceSmokeTest.php b/tests/Service/HolidayCalendarServiceSmokeTest.php
index 3bc8dd5..da2f1b9 100644
--- a/tests/Service/HolidayCalendarServiceSmokeTest.php
+++ b/tests/Service/HolidayCalendarServiceSmokeTest.php
@@ -18,19 +18,12 @@ interface IClientService { public function newClient(): IClient; }
namespace OCP\AppFramework\Utility { interface ITimeFactory { public function getTime(): int; } }
namespace Psr\Log { interface LoggerInterface { public function warning(string $message, array $context = []): void; } }
namespace OCA\LocalBase\AppInfo {
- if (!class_exists(Application::class)) {
+ if (!class_exists(Application::class, false)) {
final class Application { public const APP_ID = 'localbase'; }
}
}
namespace {
- require_once __DIR__ . '/../../lib/Calendar/CalendarContext.php';
- require_once __DIR__ . '/../../lib/Calendar/CalendarContextSettingsService.php';
- require_once __DIR__ . '/../../lib/Calendar/HolidayPeriod.php';
- require_once __DIR__ . '/../../lib/Calendar/HolidayCalendar.php';
- require_once __DIR__ . '/../../lib/Calendar/OpenHolidaysClient.php';
- require_once __DIR__ . '/../../lib/Calendar/HolidayCalendarCacheStore.php';
- require_once __DIR__ . '/../../lib/Calendar/HolidayCalendarService.php';
use OCA\LocalBase\Calendar\CalendarContextSettingsService;
use OCA\LocalBase\Calendar\HolidayCalendarCacheStore;
diff --git a/tests/Service/IntegrationCapabilityQueryEventSmokeTest.php b/tests/Service/IntegrationCapabilityQueryEventSmokeTest.php
index c571cb7..8c411d7 100644
--- a/tests/Service/IntegrationCapabilityQueryEventSmokeTest.php
+++ b/tests/Service/IntegrationCapabilityQueryEventSmokeTest.php
@@ -6,8 +6,6 @@
eval('namespace OCP\\EventDispatcher; class Event { public function __construct() {} }');
}
-require_once __DIR__ . '/../../lib/Integration/AdIntegrationCapabilities.php';
-require_once __DIR__ . '/../../lib/Integration/IntegrationCapabilityQueryEvent.php';
use OCA\LocalBase\Integration\AdIntegrationCapabilities;
use OCA\LocalBase\Integration\IntegrationCapabilityQueryEvent;
diff --git a/tests/Service/IntegrationCapabilityServiceSmokeTest.php b/tests/Service/IntegrationCapabilityServiceSmokeTest.php
index eb7b536..685ce4c 100644
--- a/tests/Service/IntegrationCapabilityServiceSmokeTest.php
+++ b/tests/Service/IntegrationCapabilityServiceSmokeTest.php
@@ -8,9 +8,6 @@ interface IEventDispatcher { public function dispatchTyped(object $event): objec
}
namespace {
- require_once __DIR__ . '/../../lib/Integration/AdIntegrationCapabilities.php';
- require_once __DIR__ . '/../../lib/Integration/IntegrationCapabilityQueryEvent.php';
- require_once __DIR__ . '/../../lib/Service/IntegrationCapabilityService.php';
use OCA\LocalBase\Integration\AdIntegrationCapabilities;
use OCA\LocalBase\Integration\IntegrationCapabilityQueryEvent;
diff --git a/tests/Service/OrganizationDirectoryStatusServiceSmokeTest.php b/tests/Service/OrganizationDirectoryStatusServiceSmokeTest.php
index 343663f..fd8b01a 100644
--- a/tests/Service/OrganizationDirectoryStatusServiceSmokeTest.php
+++ b/tests/Service/OrganizationDirectoryStatusServiceSmokeTest.php
@@ -17,8 +17,6 @@ interface IGroupManager { public function get($gid); }
}
namespace {
- require_once __DIR__ . '/../../lib/Organization/AdOrganizationDefinition.php';
- require_once __DIR__ . '/../../lib/Service/OrganizationDirectoryStatusService.php';
use OCA\LocalBase\Organization\AdOrganizationDefinition;
use OCA\LocalBase\Service\OrganizationDirectoryStatusService;
diff --git a/tests/Service/RefreshHolidayCalendarJobSmokeTest.php b/tests/Service/RefreshHolidayCalendarJobSmokeTest.php
index 6a70b66..960b5f1 100644
--- a/tests/Service/RefreshHolidayCalendarJobSmokeTest.php
+++ b/tests/Service/RefreshHolidayCalendarJobSmokeTest.php
@@ -26,7 +26,6 @@ public function forYear(int $year, bool $forceRefresh = false): void { $this->ca
}
namespace {
- require_once __DIR__ . '/../../lib/BackgroundJob/RefreshHolidayCalendarJob.php';
use OCA\LocalBase\BackgroundJob\RefreshHolidayCalendarJob;
use OCA\LocalBase\Calendar\CalendarContextSettingsService;
diff --git a/tests/Service/RefreshHolidayCalendarMigrationSmokeTest.php b/tests/Service/RefreshHolidayCalendarMigrationSmokeTest.php
index c6bdf90..1995000 100644
--- a/tests/Service/RefreshHolidayCalendarMigrationSmokeTest.php
+++ b/tests/Service/RefreshHolidayCalendarMigrationSmokeTest.php
@@ -15,7 +15,6 @@ abstract class SimpleMigrationStep {}
}
namespace {
- require_once __DIR__ . '/../../lib/Migration/Version000001Date202607220001.php';
use OCA\LocalBase\BackgroundJob\RefreshHolidayCalendarJob;
use OCA\LocalBase\Migration\Version000001Date202607220001;
diff --git a/tests/Service/ScheduleConflictQueryEventSmokeTest.php b/tests/Service/ScheduleConflictQueryEventSmokeTest.php
index 5054092..b815309 100644
--- a/tests/Service/ScheduleConflictQueryEventSmokeTest.php
+++ b/tests/Service/ScheduleConflictQueryEventSmokeTest.php
@@ -3,8 +3,6 @@
declare(strict_types=1);
if (!class_exists(\OCP\EventDispatcher\Event::class)) eval('namespace OCP\\EventDispatcher; class Event { public function __construct() {} }');
-require_once __DIR__ . '/../../lib/Calendar/ScheduleConflict.php';
-require_once __DIR__ . '/../../lib/Calendar/ScheduleConflictQueryEvent.php';
use OCA\LocalBase\Calendar\ScheduleConflict;
use OCA\LocalBase\Calendar\ScheduleConflictQueryEvent;
diff --git a/tests/Service/StandaloneAppNavigationServiceSmokeTest.php b/tests/Service/StandaloneAppNavigationServiceSmokeTest.php
index 6827983..3a9b52e 100644
--- a/tests/Service/StandaloneAppNavigationServiceSmokeTest.php
+++ b/tests/Service/StandaloneAppNavigationServiceSmokeTest.php
@@ -11,8 +11,8 @@ interface INavigationManager { public const TYPE_APPS = 'link'; public function
namespace OCP\App { interface IAppManager { public function isEnabledForUser($appId, $user = null); } }
namespace {
- require_once __DIR__ . '/../../lib/Service/StandaloneAppNavigationService.php';
+ use OCA\LocalBase\Catalog\AdProductCatalog;
use OCA\LocalBase\Service\StandaloneAppNavigationService;
use OCP\App\IAppManager;
use OCP\INavigationManager;
@@ -29,21 +29,43 @@ public function linkToRoute(string $routeName, array $arguments = []): string {
public function imagePath(string $appName, string $file): string { return '/image/' . $appName . '/' . $file; }
};
- $service = new StandaloneAppNavigationService($session, $apps, $navigation, $url);
- $service->addWhenStandalone('adcalendar', 'Kalender', 'adcalendar.page.index', 'app.svg', 80);
+ $service = new StandaloneAppNavigationService($session, $apps, $navigation, $url, new AdProductCatalog());
+ $service->addCatalogProductWhenStandalone('adrecruitment', 'Recruitment', 'app.svg');
$entry = ($navigation->entries[0] ?? static fn(): array => [])();
- if (($entry['id'] ?? null) !== 'adcalendar' || ($entry['href'] ?? null) !== '/route/adcalendar.page.index' || ($entry['icon'] ?? null) !== '/image/adcalendar/app.svg') {
+ if (($entry['id'] ?? null) !== 'adrecruitment' || ($entry['href'] ?? null) !== '/route/adrecruitment.page.index'
+ || ($entry['icon'] ?? null) !== '/image/adrecruitment/app.svg' || ($entry['order'] ?? null) !== 84) {
throw new RuntimeException('Standalone-Navigation wurde nicht korrekt registriert.');
}
+ $service->addWhenStandalone('adcalendar', 'Kalender', 'manually.duplicated.route', 'app.svg', 999);
+ $catalogEntry = ($navigation->entries[1] ?? static fn(): array => [])();
+ if (($catalogEntry['href'] ?? null) !== '/route/adcalendar.page.index' || ($catalogEntry['order'] ?? null) !== 80) {
+ throw new RuntimeException('Bestehende Standalone-Consumer verwenden nicht die kanonische Katalogroute und -reihenfolge.');
+ }
+
+ $service->addWhenStandalone('other_app', 'Andere App', 'other.page.index', 'other.svg', 91);
+ $genericEntry = ($navigation->entries[2] ?? static fn(): array => [])();
+ if (($genericEntry['href'] ?? null) !== '/route/other.page.index' || ($genericEntry['order'] ?? null) !== 91) {
+ throw new RuntimeException('Nicht katalogisierte Fachbereiche verlieren den generischen Navigationsvertrag.');
+ }
+
$apps->suiteEnabled = true;
$service->addWhenStandalone('adurlaub', 'Urlaub', 'adurlaub.page.index', 'app.svg', 81);
- if (count($navigation->entries) !== 1) throw new RuntimeException('Bei aktiver OrgSuite darf kein Fachapp-Eintrag hinzukommen.');
+ if (count($navigation->entries) !== 3) throw new RuntimeException('Bei aktiver OrgSuite darf kein Fachapp-Eintrag hinzukommen.');
$anonymousNavigation = new class implements INavigationManager { public array $entries = []; public function add(callable $entry): void { $this->entries[] = $entry; } };
$anonymousSession = new class implements IUserSession { public function getUser(): ?IUser { return null; } };
(new StandaloneAppNavigationService($anonymousSession, $apps, $anonymousNavigation, $url))->addWhenStandalone('adroom', 'Räume', 'adroom.page.index', 'app.svg');
if ($anonymousNavigation->entries !== []) throw new RuntimeException('Anonyme Navigation wurde registriert.');
+ $missingCatalog = new AdProductCatalog(__DIR__ . '/missing-catalog.json');
+ $missingNavigation = new class implements INavigationManager { public array $entries = []; public function add(callable $entry): void { $this->entries[] = $entry; } };
+ $apps->suiteEnabled = false;
+ (new StandaloneAppNavigationService($session, $apps, $missingNavigation, $url, $missingCatalog))
+ ->addCatalogProductWhenStandalone('adrecruitment', 'Recruitment', 'app.svg');
+ (new StandaloneAppNavigationService($session, $apps, $missingNavigation, $url, $missingCatalog))
+ ->addWhenStandalone('adcalendar', 'Kalender', 'adcalendar.page.index', 'app.svg');
+ if ($missingNavigation->entries !== []) throw new RuntimeException('Fehlender Katalogprovider erzeugt unsichere Navigation.');
+
echo "StandaloneAppNavigationServiceSmokeTest: OK\n";
}
diff --git a/tests/Service/StandaloneOrganizationSettingsSmokeTest.php b/tests/Service/StandaloneOrganizationSettingsSmokeTest.php
index 711704c..a230b15 100644
--- a/tests/Service/StandaloneOrganizationSettingsSmokeTest.php
+++ b/tests/Service/StandaloneOrganizationSettingsSmokeTest.php
@@ -8,9 +8,6 @@
namespace OCP\AppFramework\Http { class TemplateResponse { public function __construct(public string $appName, public string $templateName, public array $params = []) {} } }
namespace {
- require_once __DIR__ . '/../../lib/Service/AdProductSuiteService.php';
- require_once __DIR__ . '/../../lib/Settings/StandaloneProductAdminSection.php';
- require_once __DIR__ . '/../../lib/Settings/StandaloneOrganizationAdmin.php';
use OCA\LocalBase\Service\AdProductSuiteService;
use OCA\LocalBase\Settings\StandaloneOrganizationAdmin;
diff --git a/tests/Support/AssertionsSmokeTest.php b/tests/Support/AssertionsSmokeTest.php
index 70e9f9b..29db351 100644
--- a/tests/Support/AssertionsSmokeTest.php
+++ b/tests/Support/AssertionsSmokeTest.php
@@ -4,7 +4,6 @@
namespace OCA\LocalBase\Tests\Support;
-require_once __DIR__ . '/assertions.php';
assertSameValue(['a' => 1], ['a' => 1], 'Equal arrays should pass.');
assertContainsString('base', 'localbase', 'Needles should be detected.');
diff --git a/tests/Support/PhpTestRunner.php b/tests/Support/PhpTestRunner.php
index 3a5ed70..bf04210 100644
--- a/tests/Support/PhpTestRunner.php
+++ b/tests/Support/PhpTestRunner.php
@@ -18,13 +18,29 @@ final class PhpTestRunner {
* @param list $testDirectories
* @param list $testSuffixes
*/
- public static function run(string $root, array $lintDirectories, array $testDirectories, array $testSuffixes, string $successMessage): void {
+ public static function run(
+ string $root,
+ array $lintDirectories,
+ array $testDirectories,
+ array $testSuffixes,
+ string $successMessage,
+ bool $prependBootstrap = false,
+ ): void {
foreach (self::collect($root, $lintDirectories, static fn(string $path): bool => str_ends_with($path, '.php')) as $file) {
self::execute($root, [PHP_BINARY, '-l', self::relativePath($root, $file)]);
}
foreach (self::collect($root, $testDirectories, static fn(string $path): bool => self::hasSuffix($path, $testSuffixes)) as $file) {
- self::execute($root, [PHP_BINARY, self::relativePath($root, $file)]);
+ $command = [PHP_BINARY];
+ $bootstrap = $prependBootstrap
+ ? $root . '/tests/bootstrap.php'
+ : dirname(__DIR__) . '/bootstrap.php';
+ if (is_file($bootstrap)) {
+ $command[] = '-d';
+ $command[] = 'auto_prepend_file=' . $bootstrap;
+ }
+ $command[] = self::relativePath($root, $file);
+ self::execute($root, $command);
}
echo $successMessage . PHP_EOL;
@@ -87,14 +103,23 @@ public static function withOptionalCoverage(string $root, array $command): array
throw new \RuntimeException("Coverage-Ausgabe konnte nicht angelegt werden: {$outputDirectory}");
}
- $script = (string)($command[1] ?? 'test');
+ $script = (string)($command[array_key_last($command)] ?? 'test');
$scriptPath = str_starts_with($script, '/')
? $script
: rtrim($root, '/') . '/' . ltrim($script, '/');
$identifier = hash('sha256', $scriptPath);
$report = rtrim($outputDirectory, '/') . '/' . $identifier . '.xml';
$wrapper = rtrim($outputDirectory, '/') . '/run-' . $identifier . '.php';
+ $bootstrapOption = array_values(array_filter(
+ $command,
+ static fn(string $argument): bool => str_starts_with($argument, 'auto_prepend_file='),
+ ));
+ $bootstrap = isset($bootstrapOption[0]) ? substr($bootstrapOption[0], strlen('auto_prepend_file=')) : '';
+ $bootstrapCode = $bootstrap !== '' && is_file($bootstrap)
+ ? ' require_once ' . var_export($bootstrap, true) . ";\n"
+ : '';
$wrapperCode = " path,
+ requestToken: () => 'token-localized',
+ fetcher: async () => ({
+ ok: false,
+ status: 400,
+ text: async () => JSON.stringify({ code: 'invalid_input', error: 'Die Eingabe ist ungültig.' })
+ })
+ });
+ await assert.rejects(
+ () => localizedErrorClient.request('/api/fail-localized'),
+ (error) => error.message === 'Die Eingabe ist ungültig.'
+ && error.status === 400
+ && error.data.code === 'invalid_input'
+ );
}
run()
diff --git a/tests/run.php b/tests/run.php
index d12aa94..5c19ff3 100644
--- a/tests/run.php
+++ b/tests/run.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-require_once __DIR__ . '/Support/PhpTestRunner.php';
+require_once __DIR__ . '/bootstrap.php';
use OCA\LocalBase\Tests\Support\PhpTestRunner;
@@ -12,4 +12,5 @@
testDirectories: ['tests/Controller', 'tests/Model', 'tests/Service', 'tests/Support'],
testSuffixes: ['Test.php'],
successMessage: 'LocalBase PHP tests passed',
+ prependBootstrap: true,
);