From c2dbda5cc7891ab5aae9f5adb5f32bc8966b2f58 Mon Sep 17 00:00:00 2001 From: filzmann Date: Tue, 28 Jul 2026 18:05:40 +0200 Subject: [PATCH 01/11] Add approved LocalBase implementation tasks --- ROADMAP.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 7fce945..20708c6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,68 @@ Diese Datei bündelt geplante Erweiterungen und offene Architekturentscheidungen. Verbindliche Fach-, Sicherheits- und Architekturregeln stehen in `AGENTS.md`. +## Freigegebene Umsetzungsaufgaben + +### LB-AD-CATALOG – Kanonischen AD-Produktkatalog bereitstellen + +Status: bereit für einen ausdrücklich beauftragten Cross-App-Lauf + +- Einen kleinen versionierten, maschinenlesbaren Katalog für stabile + Produkt-ID, Produkttyp, Reihenfolge und technische Einstiegsroute + bereitstellen. +- `adcalendar`, `adplaner`, `adurlaub`, `adroom` und `adrecruitment` + aufnehmen; LocalBase und OrgSuite getrennt als Infrastruktur ausweisen. +- Standalone-Fähigkeit, Menüzugehörigkeit und Release-Bundle-Zugehörigkeit + getrennt modellieren. Sichtbare Labels bleiben l10n-fähige Consumertexte. +- Provider-Tests decken Schema, Eindeutigkeit, stabile Reihenfolge, + unbekannte Produkte und Rückwärtskompatibilität ab. +- Die abhängigen Tasks `PARENT-AD-CATALOG`, `ORGS-AD-CATALOG`, + `RECR-AD-CATALOG` und `ADS-AD-CATALOG-DOCS` gemeinsam abnehmen. + +### LB-AD-ORG-SNAPSHOT – Read-only Organisationssnapshot veröffentlichen + +Status: bereit für einen ausdrücklich beauftragten öffentlichen +LocalBase-Vertrag + +- Einen unveränderlichen read-only Snapshot mit Vertragsversion, Prüfsumme, + Gültigkeitsstatus, Rollen, Bereichen, Assistenzteams und tatsächlichen + Gruppen-IDs bereitstellen. +- Einen ungültigen oder nur aus Defaults rekonstruierten Persistenzstand + erkennbar machen; ein Consumer darf daraus keine Freigabe ableiten. +- Keine Gruppen, Mitgliedschaften oder Fachrechte verändern und keine + personenbezogenen Mitgliederlisten in den Vertrag aufnehmen. +- Provider-Tests belegen gültigen Stand, ungültige Persistenz, stabile + Prüfsumme, Versionierung und datensparsames Payload. +- Gemeinsam mit `BPM-AD-ORG-SNAPSHOT` in der Berechtigungsmatrix durch + Provider-/Consumer-Contract-Tests abnehmen. + +### 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. + +### LB-L10N – LocalBase-Oberflächen vollständig lokalisieren + +Status: bereit nach Auswahl einer Pilot-App und ihres l10n-Vertrags + +- 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 - Bestehende gemeinsame Modelle, API-, UI-, Organisations-, Integrations- und Testverträge klein, dependency-arm und stabil halten. From 075ac98a3185c5d30f175c1310238addaf40b0c8 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sat, 1 Aug 2026 18:35:42 +0200 Subject: [PATCH 02/11] Centralize the AD product catalog --- AGENTS.md | 10 +- CHANGELOG.md | 6 + README.md | 5 + ROADMAP.md | 18 +- appinfo/info.xml | 2 +- docs/manual-acceptance.md | 109 ++++++++++ lib/Catalog/AdProductCatalog.php | 204 ++++++++++++++++++ lib/Service/AdProductSuiteService.php | 35 +-- .../StandaloneAppNavigationService.php | 35 +++ .../StandaloneProductAdminSection.php | 2 +- resources/ad-product-catalog.json | 96 +++++++++ tests/Service/AdProductCatalogSmokeTest.php | 133 ++++++++++++ .../AdProductSuiteServiceSmokeTest.php | 16 +- .../ApplicationBootstrapExecutionTest.php | 1 + ...tandaloneAppNavigationServiceSmokeTest.php | 32 ++- ...tandaloneOrganizationSettingsSmokeTest.php | 1 + 16 files changed, 666 insertions(+), 39 deletions(-) create mode 100644 docs/manual-acceptance.md create mode 100644 lib/Catalog/AdProductCatalog.php create mode 100644 resources/ad-product-catalog.json create mode 100644 tests/Service/AdProductCatalogSmokeTest.php diff --git a/AGENTS.md b/AGENTS.md index da2eedc..a13f7a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,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..adadc57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 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..adbfb8d 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,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 20708c6..0e3ebf5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,22 +4,6 @@ Diese Datei bündelt geplante Erweiterungen und offene Architekturentscheidungen ## Freigegebene Umsetzungsaufgaben -### LB-AD-CATALOG – Kanonischen AD-Produktkatalog bereitstellen - -Status: bereit für einen ausdrücklich beauftragten Cross-App-Lauf - -- Einen kleinen versionierten, maschinenlesbaren Katalog für stabile - Produkt-ID, Produkttyp, Reihenfolge und technische Einstiegsroute - bereitstellen. -- `adcalendar`, `adplaner`, `adurlaub`, `adroom` und `adrecruitment` - aufnehmen; LocalBase und OrgSuite getrennt als Infrastruktur ausweisen. -- Standalone-Fähigkeit, Menüzugehörigkeit und Release-Bundle-Zugehörigkeit - getrennt modellieren. Sichtbare Labels bleiben l10n-fähige Consumertexte. -- Provider-Tests decken Schema, Eindeutigkeit, stabile Reihenfolge, - unbekannte Produkte und Rückwärtskompatibilität ab. -- Die abhängigen Tasks `PARENT-AD-CATALOG`, `ORGS-AD-CATALOG`, - `RECR-AD-CATALOG` und `ADS-AD-CATALOG-DOCS` gemeinsam abnehmen. - ### LB-AD-ORG-SNAPSHOT – Read-only Organisationssnapshot veröffentlichen Status: bereit für einen ausdrücklich beauftragten öffentlichen @@ -66,6 +50,8 @@ Status: bereit nach Auswahl einer Pilot-App und ihres l10n-Vertrags ## 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..7dece26 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ 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.9.0-rc.1 agpl Simon https://github.com/Filzmann/ad-suite diff --git a/docs/manual-acceptance.md b/docs/manual-acceptance.md new file mode 100644 index 0000000..91bd189 --- /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 | Mit neutralen Daten `planned` und `approved` über AD Urlaub bereitstellen und in AD Kalender lesen. | `planned` erscheint als `U?` ohne Blockade; `approved` erscheint 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/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/Service/AdProductSuiteService.php b/lib/Service/AdProductSuiteService.php index 887b21e..a84027b 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,18 @@ 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 { + if (!class_exists(AdProductCatalog::class)) { + require_once dirname(__DIR__) . '/Catalog/AdProductCatalog.php'; + } + return $this->catalog ??= new AdProductCatalog(); } } diff --git a/lib/Service/StandaloneAppNavigationService.php b/lib/Service/StandaloneAppNavigationService.php index 7bf3ee4..bdf71e2 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,11 @@ public function addWhenStandalone( 'order' => $order, ]); } + + private function catalog(): AdProductCatalog { + if (!class_exists(AdProductCatalog::class)) { + require_once dirname(__DIR__) . '/Catalog/AdProductCatalog.php'; + } + 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/Service/AdProductCatalogSmokeTest.php b/tests/Service/AdProductCatalogSmokeTest.php new file mode 100644 index 0000000..0375a64 --- /dev/null +++ b/tests/Service/AdProductCatalogSmokeTest.php @@ -0,0 +1,133 @@ +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..269330d 100644 --- a/tests/Service/AdProductSuiteServiceSmokeTest.php +++ b/tests/Service/AdProductSuiteServiceSmokeTest.php @@ -6,8 +6,10 @@ namespace OCP\App { interface IAppManager { public function isEnabledForUser($appId, $user = null); } } namespace { + require_once __DIR__ . '/../../lib/Catalog/AdProductCatalog.php'; require_once __DIR__ . '/../../lib/Service/AdProductSuiteService.php'; + use OCA\LocalBase\Catalog\AdProductCatalog; use OCA\LocalBase\Service\AdProductSuiteService; use OCP\App\IAppManager; @@ -15,17 +17,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/ApplicationBootstrapExecutionTest.php b/tests/Service/ApplicationBootstrapExecutionTest.php index 1e39918..9531ff8 100644 --- a/tests/Service/ApplicationBootstrapExecutionTest.php +++ b/tests/Service/ApplicationBootstrapExecutionTest.php @@ -23,6 +23,7 @@ public function registerSetting(string $type, string $setting); } namespace { + require_once __DIR__ . '/../../lib/Catalog/AdProductCatalog.php'; require_once __DIR__ . '/../../lib/Service/AdProductSuiteService.php'; require_once __DIR__ . '/../../lib/AppInfo/Application.php'; diff --git a/tests/Service/StandaloneAppNavigationServiceSmokeTest.php b/tests/Service/StandaloneAppNavigationServiceSmokeTest.php index 6827983..897eff7 100644 --- a/tests/Service/StandaloneAppNavigationServiceSmokeTest.php +++ b/tests/Service/StandaloneAppNavigationServiceSmokeTest.php @@ -11,8 +11,10 @@ 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/Catalog/AdProductCatalog.php'; 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 +31,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..b2e7e62 100644 --- a/tests/Service/StandaloneOrganizationSettingsSmokeTest.php +++ b/tests/Service/StandaloneOrganizationSettingsSmokeTest.php @@ -8,6 +8,7 @@ namespace OCP\AppFramework\Http { class TemplateResponse { public function __construct(public string $appName, public string $templateName, public array $params = []) {} } } namespace { + require_once __DIR__ . '/../../lib/Catalog/AdProductCatalog.php'; require_once __DIR__ . '/../../lib/Service/AdProductSuiteService.php'; require_once __DIR__ . '/../../lib/Settings/StandaloneProductAdminSection.php'; require_once __DIR__ . '/../../lib/Settings/StandaloneOrganizationAdmin.php'; From eda712687832a0d89089bb032d18ffd7d8ff7237 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 2 Aug 2026 13:49:28 +0200 Subject: [PATCH 03/11] Deploy main release candidates to staging --- .github/workflows/tests.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index be0b5d0..b8ca5fe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -186,3 +186,11 @@ 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 From 71480edec9aa2527cc73f7b68bf6e674e9dfd410 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 2 Aug 2026 14:23:33 +0200 Subject: [PATCH 04/11] Forward staging deployment secrets --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b8ca5fe..7170d92 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -194,3 +194,4 @@ jobs: uses: Filzmann/br-nextcloud-apps/.github/workflows/deploy-staging.yml@main with: app-id: localbase + secrets: inherit From 7d1c46f1e25c6efec2ed2716c10520cd4581bfd4 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sat, 8 Aug 2026 18:53:58 +0200 Subject: [PATCH 05/11] Publish organization v3 snapshot contract --- AGENTS.md | 1 + CHANGELOG.md | 7 ++ README.md | 5 ++ ROADMAP.md | 17 ---- appinfo/info.xml | 2 +- docs/architecture.md | 21 +++++ lib/Organization/AdOrganizationDefinition.php | 81 ++++++++++++------- .../AdOrganizationSettingsService.php | 13 ++- lib/Organization/AdOrganizationSnapshot.php | 56 +++++++++++++ .../AdOrganizationSnapshotService.php | 28 +++++++ lib/Service/AdDemoFixtureCatalog.php | 3 +- .../AdOrganizationDefinitionSmokeTest.php | 41 +++++++++- ...AdOrganizationSettingsServiceSmokeTest.php | 4 +- ...AdOrganizationSnapshotServiceSmokeTest.php | 62 ++++++++++++++ 14 files changed, 287 insertions(+), 54 deletions(-) create mode 100644 lib/Organization/AdOrganizationSnapshot.php create mode 100644 lib/Organization/AdOrganizationSnapshotService.php create mode 100644 tests/Service/AdOrganizationSnapshotServiceSmokeTest.php diff --git a/AGENTS.md b/AGENTS.md index a13f7a0..5e3762e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index adadc57..df38929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 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. diff --git a/README.md b/README.md index adbfb8d..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 diff --git a/ROADMAP.md b/ROADMAP.md index 0e3ebf5..c5636c4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,23 +4,6 @@ Diese Datei bündelt geplante Erweiterungen und offene Architekturentscheidungen ## Freigegebene Umsetzungsaufgaben -### LB-AD-ORG-SNAPSHOT – Read-only Organisationssnapshot veröffentlichen - -Status: bereit für einen ausdrücklich beauftragten öffentlichen -LocalBase-Vertrag - -- Einen unveränderlichen read-only Snapshot mit Vertragsversion, Prüfsumme, - Gültigkeitsstatus, Rollen, Bereichen, Assistenzteams und tatsächlichen - Gruppen-IDs bereitstellen. -- Einen ungültigen oder nur aus Defaults rekonstruierten Persistenzstand - erkennbar machen; ein Consumer darf daraus keine Freigabe ableiten. -- Keine Gruppen, Mitgliedschaften oder Fachrechte verändern und keine - personenbezogenen Mitgliederlisten in den Vertrag aufnehmen. -- Provider-Tests belegen gültigen Stand, ungültige Persistenz, stabile - Prüfsumme, Versionierung und datensparsames Payload. -- Gemeinsam mit `BPM-AD-ORG-SNAPSHOT` in der Berechtigungsmatrix durch - Provider-/Consumer-Contract-Tests abnehmen. - ### LB-BR-GROUPS – Gemeinsamen BR-Gruppenvertrag bereitstellen Status: bereit nach Klärung der Mitgliedschaftsinvariante diff --git a/appinfo/info.xml b/appinfo/info.xml index 7dece26..750aa79 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ 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.9.0-rc.1 + 0.10.0-rc.1 agpl Simon https://github.com/Filzmann/ad-suite diff --git a/docs/architecture.md b/docs/architecture.md index 2ea208d..f7208aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,6 +51,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/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/tests/Service/AdOrganizationDefinitionSmokeTest.php b/tests/Service/AdOrganizationDefinitionSmokeTest.php index 654380a..334e509 100644 --- a/tests/Service/AdOrganizationDefinitionSmokeTest.php +++ b/tests/Service/AdOrganizationDefinitionSmokeTest.php @@ -15,7 +15,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 +82,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 +119,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/AdOrganizationSettingsServiceSmokeTest.php b/tests/Service/AdOrganizationSettingsServiceSmokeTest.php index 0599bb3..0a3e537 100644 --- a/tests/Service/AdOrganizationSettingsServiceSmokeTest.php +++ b/tests/Service/AdOrganizationSettingsServiceSmokeTest.php @@ -42,9 +42,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..50801c1 --- /dev/null +++ b/tests/Service/AdOrganizationSnapshotServiceSmokeTest.php @@ -0,0 +1,62 @@ +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"; +} From 2a32428fb9f837815a9dde27f1e0dfb242829354 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sat, 8 Aug 2026 19:17:17 +0200 Subject: [PATCH 06/11] Support localized API error responses --- CHANGELOG.md | 4 ++++ appinfo/info.xml | 2 +- js/api/api-client.js | 6 +++++- tests/js/api-client-smoke.js | 17 +++++++++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df38929..9816742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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. diff --git a/appinfo/info.xml b/appinfo/info.xml index 750aa79..c3a2920 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ 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.10.0-rc.1 + 0.10.0-rc.2 agpl Simon https://github.com/Filzmann/ad-suite 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/tests/js/api-client-smoke.js b/tests/js/api-client-smoke.js index 1e3af4f..fa29ef1 100644 --- a/tests/js/api-client-smoke.js +++ b/tests/js/api-client-smoke.js @@ -124,6 +124,23 @@ async function run() { && error.status === 500 && Object.keys(error.data).length === 0 ); + + const localizedErrorClient = new ApiClient({ + appId: 'demo', + generateUrl: (path) => 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() From ba0c12abf8a9735520b1356d559ebc6712f28cbd Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 9 Aug 2026 12:13:20 +0200 Subject: [PATCH 07/11] Align app metadata with Nextcloud schema --- appinfo/info.xml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index c3a2920..ee25c5b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -8,16 +8,16 @@ 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 + From 86d49107ed4533173138f29d817eca44bcdbe896 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 9 Aug 2026 13:42:19 +0200 Subject: [PATCH 08/11] chore: sync app work instructions --- .agents/skills/work-in-nextcloud-app/SKILL.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.agents/skills/work-in-nextcloud-app/SKILL.md b/.agents/skills/work-in-nextcloud-app/SKILL.md index 35e0ff6..4650c39 100644 --- a/.agents/skills/work-in-nextcloud-app/SKILL.md +++ b/.agents/skills/work-in-nextcloud-app/SKILL.md @@ -57,7 +57,9 @@ Stop immediately if production systems, Git history rewriting, new production de - Develop executable UI logic test-first; additionally cover layout, accessibility, and Nextcloud integration with suitable smoke or browser checks. - Treat a time-boxed exploratory spike or hard-to-isolate Nextcloud integration as an explicitly justified deviation. Discard spike code or characterize it before adoption; choose the truthful broader integration level when isolation would hide the real contract. - Use the local fast entries named by `AGENTS.md`, normally `php tests/run.php` and `node tests/run-js.mjs`; dependency-light PHP smokes run in isolated processes. Run LocalBase and every affected consumer contract/smoke suite after a LocalBase contract change. -- In local pre-production, app tests may use shared LocalBase test helpers through relative repository paths. Add heavier packaging/autoload structure or a larger test framework only when path handling, runners, assertions, mocks, or fixtures are materially duplicated or impair readability. +- PHP classes must not remain coupled through distributed relative `require` or `require_once` chains. Production classes under `lib/` use Nextcloud's PSR-4 app autoloader; a bundled category-A dependency uses exactly one reproducibly generated, app-local, namespace-isolated Composer autoloader. Never use a shared workspace autoloader, shared cross-app `vendor/`, or neighboring production repository path as a delivery mechanism. Category-B services remain separate runtime apps and are consumed only through their public activation- and version-aware contracts. +- Every app uses one central app-local test bootstrap/autoloader for dependency-light PHP tests. It may resolve a test-only LocalBase helper through exactly one central transition point until a versioned development dependency exists, but individual tests must not retain direct relative LocalBase class paths after migration. Nextcloud integration tests may load the documented Nextcloud test bootstrap; template partials, explicit process/test entrypoints, and the one-time bootstrap of a bundled Composer autoloader remain justified includes rather than class-load chains. +- When a writing task first touches executable PHP or PHP tests in an app that has not yet migrated, include that app's complete autoload migration as a separate preparatory step in the same app scope. An app is complete only when distributed manual class requires and production fallback requires are gone, allowed includes are limited and reviewable, relevant app and provider/consumer PHP tests are green, and release checks contain neither development dependencies nor foreign repository paths. Documentation-only, formatting-only, and JavaScript-only work does not trigger an artificial PHP migration. - Known overall and app coverage must not decline unnoticed. Aim for at least 85 percent line coverage for new or materially changed executable code, report PHP and JavaScript separately, and fully cover security invariants regardless of percentages. Coverage is a warning and delivery indicator, not a substitute for meaningful assertions. - Do not prepare a commit or release with red relevant fast tests, contract tests, security checks, coverage gates, or delivery gates. @@ -82,6 +84,7 @@ Stop immediately if production systems, Git history rewriting, new production de ## Git and completion report +- When Simon asks for the next open steps, priorities, remaining work, or a similar outlook, include every applicable pending migration and unresolved decision from accepted ADRs and documented rollout plans. Report its current status, trigger, and required approval gate, and distinguish work executable now from work triggered by a later app change and work that is currently undecidable. Mentioning an item does not expand the current write scope or authorize a gated change. - Do not commit, push, release, deploy, or use `git add .` without Simon's explicit authorization. Stage individual files only when staging was requested. - Before a commit, show `git status --short`, `git diff --stat`, and `git diff --name-only`. Never use `git reset --hard`, `git clean`, force-push, history rewrite, or versioned backup copies. - Run relevant local tests, `git diff --check`, and the repository's own structure/fast check. For an explicitly authorized cross-app contract change, validate every provider and consumer repository from its own root and use the Parent workspace check only as an additional coordinator. From 40bb9ee18f0cfd8bbe0b8e0cd8e9821c61384520 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 9 Aug 2026 18:07:51 +0200 Subject: [PATCH 09/11] docs: move l10n rollout to future planning --- ROADMAP.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index c5636c4..73524aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,9 +18,12 @@ Status: bereit nach Klärung der Mitgliedschaftsinvariante - 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: bereit nach Auswahl einer Pilot-App und ihres l10n-Vertrags +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. From a729d6b2fdfb747a1f69c92a6a0b558f10789b40 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 9 Aug 2026 19:48:53 +0200 Subject: [PATCH 10/11] feat: publish bounded absence discovery contract --- AGENTS.md | 2 +- docs/architecture.md | 9 ++-- docs/manual-acceptance.md | 2 +- .../AbsenceEmployeeDiscoveryEvent.php | 49 +++++++++++++++++++ lib/Service/AdProductSuiteService.php | 3 -- .../StandaloneAppNavigationService.php | 3 -- ...AdSuiteAdminApiControllerExecutionTest.php | 1 - tests/Controller/ApiResponderSmokeTest.php | 1 - tests/Model/ModelApiTraitSmokeTest.php | 2 - .../AbsenceEmployeeDiscoveryEventTest.php | 43 ++++++++++++++++ tests/Service/AbsenceQueryEventSmokeTest.php | 2 - .../Service/AdDemoFixtureCatalogSmokeTest.php | 2 - .../AdOrganizationDefinitionSmokeTest.php | 3 -- ...dOrganizationPermissionPolicySmokeTest.php | 3 -- ...AdOrganizationSettingsServiceSmokeTest.php | 4 +- ...AdOrganizationSnapshotServiceSmokeTest.php | 6 +-- tests/Service/AdProductCatalogSmokeTest.php | 2 - .../AdProductSuiteServiceSmokeTest.php | 2 - .../AdSuiteAdminLayoutServiceSmokeTest.php | 1 - .../AdSuiteAdminSettingsServiceSmokeTest.php | 5 +- tests/Service/AppLoggerSmokeTest.php | 1 - .../ApplicationBootstrapExecutionTest.php | 3 -- ...alendarContextSettingsServiceSmokeTest.php | 4 +- ...emoAccountProvisioningServiceSmokeTest.php | 1 - .../GroupProvisioningServiceSmokeTest.php | 2 - .../HolidayCalendarServiceSmokeTest.php | 9 +--- ...tegrationCapabilityQueryEventSmokeTest.php | 2 - .../IntegrationCapabilityServiceSmokeTest.php | 3 -- ...izationDirectoryStatusServiceSmokeTest.php | 2 - .../RefreshHolidayCalendarJobSmokeTest.php | 1 - ...freshHolidayCalendarMigrationSmokeTest.php | 1 - .../ScheduleConflictQueryEventSmokeTest.php | 2 - ...tandaloneAppNavigationServiceSmokeTest.php | 2 - ...tandaloneOrganizationSettingsSmokeTest.php | 4 -- tests/Support/AssertionsSmokeTest.php | 1 - tests/Support/PhpTestRunner.php | 31 ++++++++++-- tests/Support/PhpTestRunnerSmokeTest.php | 1 - tests/bootstrap.php | 22 +++++++++ tests/helpers.php | 2 - tests/run.php | 3 +- 40 files changed, 157 insertions(+), 85 deletions(-) create mode 100644 lib/Calendar/AbsenceEmployeeDiscoveryEvent.php create mode 100644 tests/Service/AbsenceEmployeeDiscoveryEventTest.php create mode 100644 tests/bootstrap.php diff --git a/AGENTS.md b/AGENTS.md index 5e3762e..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. diff --git a/docs/architecture.md b/docs/architecture.md index f7208aa..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. diff --git a/docs/manual-acceptance.md b/docs/manual-acceptance.md index 91bd189..a8d0b85 100644 --- a/docs/manual-acceptance.md +++ b/docs/manual-acceptance.md @@ -69,7 +69,7 @@ Abnahme auf den dokumentierten Ausgangsstand zurückgeführt. | ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | |---|---|---|---|---|---| -| D1 | Abwesenheitsvertrag | Mit neutralen Daten `planned` und `approved` über AD Urlaub bereitstellen und in AD Kalender lesen. | `planned` erscheint als `U?` ohne Blockade; `approved` erscheint als `U` mit der vereinbarten Blockade. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| 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 | | diff --git a/lib/Calendar/AbsenceEmployeeDiscoveryEvent.php b/lib/Calendar/AbsenceEmployeeDiscoveryEvent.php new file mode 100644 index 0000000..fe12335 --- /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[$employeeUid] = true; + } + } + } + + /** @return list */ + public function employeeUids(): array { + $employeeUids = array_keys($this->employees); + sort($employeeUids, SORT_STRING); + return $employeeUids; + } +} diff --git a/lib/Service/AdProductSuiteService.php b/lib/Service/AdProductSuiteService.php index a84027b..8db6dee 100644 --- a/lib/Service/AdProductSuiteService.php +++ b/lib/Service/AdProductSuiteService.php @@ -45,9 +45,6 @@ public function label(string $appId): string { } private function catalog(): AdProductCatalog { - if (!class_exists(AdProductCatalog::class)) { - require_once dirname(__DIR__) . '/Catalog/AdProductCatalog.php'; - } return $this->catalog ??= new AdProductCatalog(); } } diff --git a/lib/Service/StandaloneAppNavigationService.php b/lib/Service/StandaloneAppNavigationService.php index bdf71e2..f1a1749 100644 --- a/lib/Service/StandaloneAppNavigationService.php +++ b/lib/Service/StandaloneAppNavigationService.php @@ -73,9 +73,6 @@ public function addWhenStandalone( } private function catalog(): AdProductCatalog { - if (!class_exists(AdProductCatalog::class)) { - require_once dirname(__DIR__) . '/Catalog/AdProductCatalog.php'; - } return $this->catalog ??= new AdProductCatalog(); } } 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..58a2169 --- /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"]); +$event->provide(['carol', 'bob']); +if ($event->employeeUids() !== ['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() !== ['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 334e509..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; 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 0a3e537..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 = []; diff --git a/tests/Service/AdOrganizationSnapshotServiceSmokeTest.php b/tests/Service/AdOrganizationSnapshotServiceSmokeTest.php index 50801c1..60ad2b9 100644 --- a/tests/Service/AdOrganizationSnapshotServiceSmokeTest.php +++ b/tests/Service/AdOrganizationSnapshotServiceSmokeTest.php @@ -12,16 +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/AdOrganizationSnapshot.php'; - require_once __DIR__ . '/../../lib/Organization/AdOrganizationSnapshotService.php'; use OCA\LocalBase\Organization\AdOrganizationSettingsService; use OCA\LocalBase\Organization\AdOrganizationSnapshotService; diff --git a/tests/Service/AdProductCatalogSmokeTest.php b/tests/Service/AdProductCatalogSmokeTest.php index 0375a64..caff73e 100644 --- a/tests/Service/AdProductCatalogSmokeTest.php +++ b/tests/Service/AdProductCatalogSmokeTest.php @@ -9,8 +9,6 @@ throw new RuntimeException('Der kanonische AD-Produktkatalog fehlt.'); } -require_once $class; - use OCA\LocalBase\Catalog\AdProductCatalog; $catalog = new AdProductCatalog($catalogFile); diff --git a/tests/Service/AdProductSuiteServiceSmokeTest.php b/tests/Service/AdProductSuiteServiceSmokeTest.php index 269330d..6b2d724 100644 --- a/tests/Service/AdProductSuiteServiceSmokeTest.php +++ b/tests/Service/AdProductSuiteServiceSmokeTest.php @@ -6,8 +6,6 @@ namespace OCP\App { interface IAppManager { public function isEnabledForUser($appId, $user = null); } } namespace { - require_once __DIR__ . '/../../lib/Catalog/AdProductCatalog.php'; - require_once __DIR__ . '/../../lib/Service/AdProductSuiteService.php'; use OCA\LocalBase\Catalog\AdProductCatalog; use OCA\LocalBase\Service\AdProductSuiteService; 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 9531ff8..ad00925 100644 --- a/tests/Service/ApplicationBootstrapExecutionTest.php +++ b/tests/Service/ApplicationBootstrapExecutionTest.php @@ -23,9 +23,6 @@ public function registerSetting(string $type, string $setting); } namespace { - require_once __DIR__ . '/../../lib/Catalog/AdProductCatalog.php'; - 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 897eff7..3a9b52e 100644 --- a/tests/Service/StandaloneAppNavigationServiceSmokeTest.php +++ b/tests/Service/StandaloneAppNavigationServiceSmokeTest.php @@ -11,8 +11,6 @@ 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/Catalog/AdProductCatalog.php'; - require_once __DIR__ . '/../../lib/Service/StandaloneAppNavigationService.php'; use OCA\LocalBase\Catalog\AdProductCatalog; use OCA\LocalBase\Service\StandaloneAppNavigationService; diff --git a/tests/Service/StandaloneOrganizationSettingsSmokeTest.php b/tests/Service/StandaloneOrganizationSettingsSmokeTest.php index b2e7e62..a230b15 100644 --- a/tests/Service/StandaloneOrganizationSettingsSmokeTest.php +++ b/tests/Service/StandaloneOrganizationSettingsSmokeTest.php @@ -8,10 +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/Catalog/AdProductCatalog.php'; - 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 = " Date: Sun, 9 Aug 2026 20:02:45 +0200 Subject: [PATCH 11/11] fix: preserve numeric absence UIDs --- lib/Calendar/AbsenceEmployeeDiscoveryEvent.php | 6 +++--- tests/Service/AbsenceEmployeeDiscoveryEventTest.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/Calendar/AbsenceEmployeeDiscoveryEvent.php b/lib/Calendar/AbsenceEmployeeDiscoveryEvent.php index fe12335..febcb88 100644 --- a/lib/Calendar/AbsenceEmployeeDiscoveryEvent.php +++ b/lib/Calendar/AbsenceEmployeeDiscoveryEvent.php @@ -14,7 +14,7 @@ * Vertrag: Der Zeitraum ist halboffen [Beginn, Ende); ohne Listener bleibt die UID-Menge leer. */ final class AbsenceEmployeeDiscoveryEvent extends Event { - /** @var array */ + /** @var array */ private array $employees = []; public function __construct(private DateTimeImmutable $start, private DateTimeImmutable $end) { @@ -35,14 +35,14 @@ public function provide(array $employeeUids): void { } $employeeUid = trim($employeeUid); if ($employeeUid !== '') { - $this->employees[$employeeUid] = true; + $this->employees['uid:' . $employeeUid] = $employeeUid; } } } /** @return list */ public function employeeUids(): array { - $employeeUids = array_keys($this->employees); + $employeeUids = array_values($this->employees); sort($employeeUids, SORT_STRING); return $employeeUids; } diff --git a/tests/Service/AbsenceEmployeeDiscoveryEventTest.php b/tests/Service/AbsenceEmployeeDiscoveryEventTest.php index 58a2169..6297fad 100644 --- a/tests/Service/AbsenceEmployeeDiscoveryEventTest.php +++ b/tests/Service/AbsenceEmployeeDiscoveryEventTest.php @@ -16,15 +16,15 @@ throw new RuntimeException('Eine unbeantwortete Discovery muss begrenzt und leer bleiben.'); } -$event->provide([' bob ', 'alice', '', 'alice', "\t"]); +$event->provide([' bob ', 'alice', '', 'alice', "\t", '0']); $event->provide(['carol', 'bob']); -if ($event->employeeUids() !== ['alice', 'bob', 'carol']) { +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() !== ['alice', 'bob', 'carol']) { +if ($event->employeeUids() !== ['0', 'alice', 'bob', 'carol']) { throw new RuntimeException('Ungültige Providerwerte dürfen die Discovery-Menge nicht erweitern.'); }