From 1efd25addaa105c169775102930790c8f07c158d Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 26 Jul 2026 20:12:25 +0200 Subject: [PATCH 1/4] feat: add shared calendar context and holiday service --- .agents/skills/test-driven-change/SKILL.md | 109 ++++++++++++ .agents/skills/work-in-nextcloud-app/SKILL.md | 12 +- AGENTS.md | 12 +- README.md | 6 +- ROADMAP.md | 12 -- appinfo/info.xml | 5 +- appinfo/routes.php | 1 + css/organization-admin.css | 3 + docs/architecture.md | 94 ++++++++++ js/admin/organization-admin.js | 31 ++++ .../RefreshHolidayCalendarJob.php | 35 ++++ lib/Calendar/CalendarContext.php | 87 ++++++++++ .../CalendarContextSettingsService.php | 40 +++++ lib/Calendar/HolidayCalendar.php | 92 ++++++++++ lib/Calendar/HolidayCalendarCacheStore.php | 38 +++++ lib/Calendar/HolidayCalendarService.php | 85 ++++++++++ lib/Calendar/HolidayPeriod.php | 60 +++++++ lib/Calendar/OpenHolidaysClient.php | 85 ++++++++++ lib/Controller/AdSuiteAdminApiController.php | 15 ++ .../Version000001Date202607220001.php | 22 +++ lib/Service/AdSuiteAdminLayoutService.php | 2 +- templates/organization-admin.php | 24 +++ ...AdSuiteAdminApiControllerExecutionTest.php | 27 ++- tests/Controller/AdSuiteAdminContractTest.php | 6 +- .../AdSuiteAdminLayoutServiceSmokeTest.php | 4 +- ...alendarContextSettingsServiceSmokeTest.php | 80 +++++++++ .../HolidayCalendarServiceSmokeTest.php | 160 ++++++++++++++++++ .../RefreshHolidayCalendarJobSmokeTest.php | 56 ++++++ ...freshHolidayCalendarMigrationSmokeTest.php | 38 +++++ tests/js/organization-admin-smoke.mjs | 4 +- tests/js/organization-dashboard-smoke.mjs | 2 +- 31 files changed, 1215 insertions(+), 32 deletions(-) create mode 100644 .agents/skills/test-driven-change/SKILL.md create mode 100644 docs/architecture.md create mode 100644 lib/BackgroundJob/RefreshHolidayCalendarJob.php create mode 100644 lib/Calendar/CalendarContext.php create mode 100644 lib/Calendar/CalendarContextSettingsService.php create mode 100644 lib/Calendar/HolidayCalendar.php create mode 100644 lib/Calendar/HolidayCalendarCacheStore.php create mode 100644 lib/Calendar/HolidayCalendarService.php create mode 100644 lib/Calendar/HolidayPeriod.php create mode 100644 lib/Calendar/OpenHolidaysClient.php create mode 100644 lib/Migration/Version000001Date202607220001.php create mode 100644 tests/Service/CalendarContextSettingsServiceSmokeTest.php create mode 100644 tests/Service/HolidayCalendarServiceSmokeTest.php create mode 100644 tests/Service/RefreshHolidayCalendarJobSmokeTest.php create mode 100644 tests/Service/RefreshHolidayCalendarMigrationSmokeTest.php diff --git a/.agents/skills/test-driven-change/SKILL.md b/.agents/skills/test-driven-change/SKILL.md new file mode 100644 index 0000000..6c148dd --- /dev/null +++ b/.agents/skills/test-driven-change/SKILL.md @@ -0,0 +1,109 @@ +--- +name: test-driven-change +description: Deliver observable behavior changes through a verified Red–Green–Refactor cycle. Use for new features, bug fixes, domain logic, permission changes, API behavior, data changes, and contract changes; do not use automatically for documentation-only, formatting, generated-file, or mechanical changes without testable behavior. +--- + +# Deliver a test-driven change + +## Establish the proof + +Before writing the test, record: + +- the domain invariant; +- the observable target behavior; +- the defect or regression the test must detect; +- the chosen test level; +- what the test proves and what it does not prove; +- relevant negative and boundary cases. + +Choose the lowest level that proves the real contract without replacing +observable behavior with implementation detail. Use a higher integration, +contract, smoke, or end-to-end level when the lower level cannot exercise the +relevant boundary truthfully. For a bug fix, make the smallest proof a +regression test. + +## Red + +1. Write the smallest meaningful test before changing production code. +2. Run that test and record the command, exit code, and concrete failure or + assertion difference. +3. Confirm that it fails for the expected domain reason: the target behavior + is missing or wrong. +4. Do not proceed to Green when: + - the test is immediately green and is not an explicitly justified + characterization test; + - it fails only because of infrastructure, syntax, fixture, or configuration + defects; + - it does not reach the target behavior; + - it checks only a mock call although observable behavior can be checked + meaningfully. + +Repair a defective test environment without adding the target production +behavior, then repeat Red. An immediately green test can preserve known +behavior as a characterization test, but is not evidence of a TDD Red step. + +## Green + +1. Make only the smallest production-code change needed to satisfy the test. +2. Add no speculative abstraction or unrelated refactoring. +3. Run the new test again. +4. Run the relevant existing regression suite. + +## Cover risk-specific evidence + +For permission changes, verify at least: + +- unauthorized access is rejected; +- authorized access succeeds; +- a foreign or manipulated object ID causes no data change. + +For database or migration changes, verify at least: + +- fresh installation on an empty schema; +- upgrade from at least the immediately relevant prior version; +- preservation or correct migration of existing data; +- no incomplete state after failure. + +For shared libraries or contracts, verify: + +- relevant provider and consumer contract tests; +- every affected dependent app; +- backward compatibility or an explicitly documented break. + +For write operations, verify: + +- the return value or HTTP result; +- the persisted state; +- absence of unwanted side effects; +- repeated execution when idempotency is relevant. + +These checks supplement applicable repository stop gates. They do not authorize +database, permission, cross-repository, or production changes. + +## Refactor + +Refactor only after the new test and relevant regression suite are green. +Change no domain behavior. Run the new test and relevant existing tests again +after refactoring. + +## Handle non-behavioral changes and deviations + +For documentation-only, formatting, generated-file, or mechanical changes +without meaningfully testable behavior, use the suitable deterministic syntax, +contract, generation, layout, link, or structure check instead of an artificial +TDD cycle. + +State and justify every deviation from the test-driven workflow. Do not treat a +test that was never observed red as TDD evidence. + +## Report + +Include: + +- the protected invariant and chosen test level; +- the initially red test and expected failure reason; +- the minimal implementation; +- executed focused, regression, and contract tests as applicable; +- concrete commands, results, and exit codes; +- remaining untested risks; +- justified deviations. diff --git a/.agents/skills/work-in-nextcloud-app/SKILL.md b/.agents/skills/work-in-nextcloud-app/SKILL.md index c06b2d3..35e0ff6 100644 --- a/.agents/skills/work-in-nextcloud-app/SKILL.md +++ b/.agents/skills/work-in-nextcloud-app/SKILL.md @@ -52,15 +52,23 @@ Stop immediately if production systems, Git history rewriting, new production de ## Test-first and coverage -- New development and bug fixes normally follow Red – Green – Refactor. Start a bug fix with a regression test and a refactoring with characterization tests. Develop domain logic, permissions, hierarchies, conflicts, and validation test-first. +- Use the locally available sibling skill `test-driven-change` for every new feature, bug fix, domain rule, permission change, API behavior, data change, or contract change. It is the sole detailed Red–Green–Refactor workflow; this section adds only Nextcloud-app test selection and coverage requirements. - API changes cover success, validation failure, and typical Allow/Deny cases. Cross-app contracts have provider and consumer contract tests. Use integration/DDEV tests for migrations and repository behavior when unit tests cannot represent the real contract. - Develop executable UI logic test-first; additionally cover layout, accessibility, and Nextcloud integration with suitable smoke or browser checks. -- Permitted test-first entry exceptions are time-boxed exploratory spikes, purely declarative text/metadata or trivial presentation changes, and hard-to-isolate Nextcloud integration where a broader integration test is more truthful. Discard spike code or characterize it before adoption; give declarative changes appropriate syntax, contract, layout, or visibility 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. - 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. +## Persistent state and migrations + +- Before implementing a feature that changes persistent domain objects, determine the complete state model: allowed and forbidden starting states, preconditions, target state, side effects, error states, retry or repetition behavior, and relevant concurrency conflicts. +- Do not expose unrestricted generic setters for status changes governed by domain transition rules. Encapsulate allowed transitions in the domain model or one clearly responsible application service and cover positive, negative, and failure cases. +- Before a database change that can encounter existing data, document the old and new schema, transformation rules, known existing-data variants, integrity conditions, transaction boundary, resumability, and rollback limits. +- Such a database change requires at least a fresh-install test, an upgrade test from the relevant previous version with synthetic existing data, domain data- and relationship-integrity checks, handling of invalid or contradictory legacy data, and an application test on the migrated schema. +- Never modify a published migration after the fact. Correct it with a new migration. + ## DDEV, Nextcloud, and hosting safety - Prefer local PHP/Node checks and batch DDEV checks. Control the shared DDEV project only from its documented `nextcloud-dev` root when that separate Parent workspace is actually available. diff --git a/AGENTS.md b/AGENTS.md index 47f5cd8..da2eedc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,9 @@ Nextcloud-App-ID: localbase Die priorisierte Produktplanung und offene Entscheidungen stehen in `ROADMAP.md`; verbindliche Fach-, Sicherheits- und Architekturregeln bleiben in dieser Datei. +Der ausführliche Katalog öffentlicher Verträge steht in +`docs/architecture.md`; diese Datei hält die bei jeder Arbeit benötigten +Cross-App-Grenzen und Prüfungen. ## Zweck @@ -21,6 +24,8 @@ Aktuell enthalten: - 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. +- `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. - `AdSuiteAdminSettingsService` speichert app-übergreifend verwendete Peer-Freigaben semantisch nach Rollen und stellt sie AD Kalender, AD Urlaub und der administrativen OrgSuite-Oberfläche gemeinsam bereit. - 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. @@ -104,9 +109,10 @@ Einzelne Checks, die durch die Testlaeufer gebuendelt werden: ## DDEV -Die gemeinsame lokale Nextcloud-DDEV-Umgebung liegt ausserhalb dieses Repos: - - ~/projects/br-nextcloud-apps/nextcloud-dev +Die gemeinsame Nextcloud-DDEV-Umgebung wird aus dem dokumentierten +Parent-Unterverzeichnis `nextcloud-dev` gesteuert. Bei einem eigenständigen +Checkout ist der lokale DDEV-Pfad zuerst anhand der realen Umgebung zu +ermitteln. Die App wird nach Nextcloud gemountet unter: diff --git a/README.md b/README.md index 9d505d6..763e8c9 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,12 @@ Gemeinsame Basisbausteine für die lokalen AD- und BR-Nextcloud-Apps. LocalBase ## Installation -Das Releasearchiv nach `custom_apps/` entpacken und als HTTP-Benutzer aktivieren: +Auf Staging- und Zielsystemen werden Nextcloud-Root, `custom_apps`, CLI-PHP und +Runtimebenutzer aus der realen Konfiguration ermittelt. Danach wird LocalBase +im vorgesehenen Runtimekontext aktiviert: ```bash -sudo -u www-data php occ app:enable localbase + occ app:enable localbase ``` Auf Staging- und Zielsystemen wird LocalBase nicht als separates Fachprodukt installiert, sondern automatisch durch den geprüften Produktinstaller. Die vollständige Installationsreihenfolge und Prüfschritte stehen im öffentlichen [AD-Suite-Projekt](https://github.com/Filzmann/ad-suite). diff --git a/ROADMAP.md b/ROADMAP.md index c75beca..7fce945 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,18 +8,6 @@ Diese Datei bündelt geplante Erweiterungen und offene Architekturentscheidungen - Ö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. -## Umgesetzt - -- Rollen und Bereiche lassen sich im Adminbereich per Drag-and-drop oder gleichwertig per Tastatur sortieren. Drag-and-drop im Organigramm darf außerdem direkte Hierarchiebeziehungen ändern; die serverseitige Zyklusprüfung bleibt verbindlich. -- Das Organigramm verwendet kompakte Diagrammknoten, Positionierung sowie gerichtete Pfeile. Bereichsrollen werden je Bürobereich aufgefächert; Verbindungen zwischen zwei Bereichsrollen gelten jeweils innerhalb desselben Bereichs. -- Karten derselben Hierarchieebene lassen sich per Drag-and-drop einschließlich einer Ablage zwischen zwei Karten sowie über zugängliche Links-/Rechts-Schaltflächen global anordnen. Diese Diagrammordnung ist eine rein visuelle Organisationsdarstellung und bleibt technisch von der fachlichen Rollen-/Bereichsreihenfolge getrennt. -- Das aktuell sichtbare Organigramm lässt sich clientseitig als bearbeitbares Draw.io-Diagramm, hochauflösendes PNG und skalierbares Vektor-PDF direkt herunterladen. Die Aufnahme zugeordneter Nutzer*innen muss für jeden Export ausdrücklich aktiviert werden. -- Rollen können ausdrücklich als Einzelposition markiert werden. Organisationsweite Einzelpositionen sowie bereichsbezogene BL-/StvBL-Positionen zeigen ihre Nextcloud-Gruppenbesetzung im Diagramm; fehlende und mehrfache Besetzungen werden sichtbar diagnostiziert. -- Die Gruppenoptionen werden über einen Edit-Stift der Diagrammkarten in einem Seitenpanel mit sichtbaren Erklärungen ihrer fachlichen Wirkung bearbeitet. Technische Zuordnungen sind eingeklappt; die fachliche Reihenfolge bleibt separat kompakt sortierbar, Bereiche und Urlaubsansichten erscheinen als aufklappbare Karten. -- Alle Haupt-, Organisations- und Rechteblöcke der AD-Administration lassen sich wie Dashboard-Widgets unabhängig einklappen und per Drag-and-drop oder Tastatur verschieben. Die Organisationsbereiche stehen dabei als eigenständige Cards ohne gemeinsamen äußeren Kasten; das Organigramm nutzt stets die volle verfügbare Breite. Diese Ansicht wird über die native Nextcloud-Benutzerkonfiguration persönlich und geräteübergreifend gespeichert; fachliche Reihenfolgen und Rechte bleiben davon getrennt. -- Das Organigramm bleibt automatisch hierarchisch angeordnet. Kompakte Karten benötigen innerhalb einer Hierarchieebene nur ihre in Grenzen variable Inhaltsbreite und stehen platzsparend nebeneinander. Die Ansicht unterstützt einen zugänglichen persönlichen Zoom von 50 bis 150 Prozent sowie Scrollen und Zeiger-Pan des sichtbaren Ausschnitts. Nur der Zoom wird geräteübergreifend gespeichert; Exporte verwenden weiterhin die vollständige logische Diagrammgröße. -- Organisationsvertrag Version 2 ergänzt stellvertretende PDL, Büroorganisation Pflege, Fahrzeugverwaltung und Empfang additiv in bestehenden Konfigurationen. Stv. PDL führt Pflegefachkräfte und Büroorganisation Pflege; Fahrzeugverwaltung ist GF-Digi und Empfang dem Sekretariat unterstellt. - ## Geplante Erweiterungen - Neue gemeinsame Bausteine werden erst aufgenommen, wenn mindestens zwei Apps dieselbe Semantik und einen gemeinsam testbaren Vertrag benötigen. diff --git a/appinfo/info.xml b/appinfo/info.xml index b4b30fe..3a89904 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.7.0-rc.7 + 0.8.0-rc.2 agpl Simon https://github.com/Filzmann/ad-suite @@ -13,6 +13,9 @@ https://github.com/Filzmann/nextcloud-localbase LocalBase tools + + OCA\LocalBase\BackgroundJob\RefreshHolidayCalendarJob + diff --git a/appinfo/routes.php b/appinfo/routes.php index d580341..5b9a02f 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -4,6 +4,7 @@ return ['routes' => [ ['name' => 'ad_suite_admin_api#settings', 'url' => '/api/ad-suite/admin/settings', 'verb' => 'GET'], + ['name' => 'ad_suite_admin_api#saveCalendarContext', 'url' => '/api/ad-suite/admin/calendar-context', 'verb' => 'PUT'], ['name' => 'ad_suite_admin_api#saveOrganization', 'url' => '/api/ad-suite/admin/organization', 'verb' => 'PUT'], ['name' => 'ad_suite_admin_api#savePermissions', 'url' => '/api/ad-suite/admin/permissions', 'verb' => 'PUT'], ['name' => 'ad_suite_admin_api#saveLayout', 'url' => '/api/ad-suite/admin/layout', 'verb' => 'PUT'], diff --git a/css/organization-admin.css b/css/organization-admin.css index 5d288fa..f8a2d1b 100644 --- a/css/organization-admin.css +++ b/css/organization-admin.css @@ -129,6 +129,9 @@ .orgs-empty { color: var(--color-text-maxcontrast); font-size: var(--font-size-small); } .orgs-checkbox-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 6px 12px; margin: 8px 0 14px; } .orgs-checkbox-grid label { display: flex; align-items: center; min-height: 32px; } +.orgs-calendar-context-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: 10px 14px; } +.orgs-calendar-context-fields label { display: grid; align-content: start; gap: 4px; min-width: 0; } +.orgs-calendar-context-fields input { width: 100%; box-sizing: border-box; } .orgs-panel form > button[type="submit"] { margin-top: 10px; } @media (max-width: 700px) { .orgs-panel { padding: 10px; } diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..2ea208d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,94 @@ +# Öffentliche Verträge von LocalBase + +Diese Datei dokumentiert den geltenden Ist-Stand der gemeinsamen +LocalBase-Verträge. Neue gemeinsame Verträge entstehen erst nach dem in +`AGENTS.md` beschriebenen Cross-App-Stop und mit Provider-/Consumer-Tests. + +## Grundbausteine + +LocalBase stellt `ApiResponder`, `ModelApiTrait`, `AppLogger`, +`GroupProvisioningService`, die JavaScript-Bausteine `ApiClient`, +`Repository`, `Model`, `Notice` und kleine UI-Primitives bereit. Gemeinsame +Test-Helper bleiben test-only, fachlich neutral und dependency-arm; der +`PhpTestRunner` sammelt dependency-arme PHP-Smokes deterministisch und führt +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 +Abwesenheiten read-only Konflikte aus optionalen Planungsapps; Provider +löschen oder verändern keine Daten. + +`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 beeinflussen ausschließlich individuelle Anzeigen. + +`HolidayCalendarService` liefert Schulferien und gesetzliche Feiertage als +validierten read-only Jahresvertrag. `OpenHolidaysClient` ist der einzige +Provideradapter; `HolidayCalendarCacheStore` hält regionsgebundene +Jahresstände in LocalBase-AppConfig. Ein täglicher Hintergrundjob aktualisiert +das laufende und die zwei folgenden Jahre. Bei Providerfehlern bleibt ein +vorhandener Stand `stale`; Erstabrufe werden sicher als `unavailable` +ausgewiesen und nach kurzer Sperrfrist erneut versucht. + +## AD-Organisationsvertrag + +`AdOrganizationDefinition`, `AdOrganizationSettingsService`, +`AdOrganizationHierarchy` und `AdOrganizationPermissionPolicy` bilden +konfigurierbare Gruppen, Anzeigenamen, Bereiche, Ansichten, Hierarchie und +Peergrenzen ab. Rollen und Bereiche werden über stabile semantische Schlüssel +referenziert; konfigurierbare Gruppen-IDs oder Anzeigenamen sind keine +Fachschlüssel. + +Die gemeinsame Reihenfolge umfasst unter anderem Fahrzeugverwaltung nach IT, +Empfang nach Sekretariat sowie im Pflegebereich stellvertretende PDL, +Büroorganisation Pflege und PFK. Der Organisationsvertrag Version 2 ergänzt +diese Rollen, Kanten und Urlaubsansichten additiv. Bestehende Werte bleiben +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. + +`AdSuiteAdminSettingsService` speichert app-übergreifende Peerfreigaben +semantisch nach Rollen. Die Organisationsdefinition und diese Freigaben liegen +zentral in LocalBase-AppConfig. Bei Einzelinstallation erscheinen sie im +Adminabschnitt des Fachprodukts, ab zwei Produkten im OrgSuite-Adminabschnitt. + +## Organisationseditor und persönliche Darstellung + +Fachliche Rolleneinstellungen werden in einem zugänglichen Seitenpanel +bearbeitet. Technische Gruppen-IDs bleiben eingeklappt; Rollenreihenfolge, +Bereiche und Urlaubsansichten bleiben getrennte fachliche Einstellungen. + +`diagramOrder` speichert ausschließlich die visuelle Links-rechts-Anordnung +von Organigrammkarten innerhalb ihrer Hierarchieebene. Sie verändert weder +Rollen-/Bereichsreihenfolgen noch Kalender, Rechte oder Hierarchiekanten. Das +Organigramm bleibt automatisch nach Hierarchieebenen angeordnet; freie +X-/Y-Positionen gehören nicht zum Vertrag. + +Haupt-, Organisations- und Rechteblöcke können eingeklappt und per +Drag-and-drop oder Tastatur verschoben werden. Reihenfolge und Einklappzustand +sind persönliche UI-Präferenzen in `IUserConfig`. Der persönliche Zoom reicht +in 10-Prozent-Schritten von 50 bis 150 Prozent; der verschobene Ausschnitt +bleibt flüchtig. Diese Werte verändern keine fachlichen Ordnungen, Rechte oder +Exportgrößen. + +Draw.io-, PNG- und PDF-Export arbeiten ausschließlich clientseitig mit dem +sichtbaren Stand und ohne Serverablage oder externe Exportdienste. +Zugeordnete Nutzer*innen werden nur nach ausdrücklicher, standardmäßig +deaktivierter Auswahl aufgenommen. + +## Optionale Integration und Navigation + +`IntegrationCapabilityQueryEvent`, `AdIntegrationCapabilities` und +`IntegrationCapabilityService` beschreiben optionale Cross-App-Fähigkeiten. +Ein leerer Snapshot ist ein zulässiger Standalone-Zustand und erweitert keine +Berechtigungen. + +`StandaloneAppNavigationService` registriert Fachapp-Einstiege nur ohne +aktive OrgSuite. `AdProductSuiteService` und dynamische Settings-Adapter +platzieren die gemeinsame Organisationsverwaltung bei Einzelinstallation im +Fachprodukt. OrgSuite bindet den vollständig in LocalBase liegenden +Organisationseditor ab zwei Produkten lediglich als Adminadapter ein. diff --git a/js/admin/organization-admin.js b/js/admin/organization-admin.js index 18c1311..2aedd6b 100644 --- a/js/admin/organization-admin.js +++ b/js/admin/organization-admin.js @@ -7,6 +7,7 @@ }); const notice = new window.LocalBase.ui.Notice('orgs-admin-notice', { baseClass: 'orgs-notice', typeClassPrefix: 'orgs-notice--' }); const organizationForm = document.getElementById('orgs-organization-form'); + const calendarContextForm = document.getElementById('orgs-calendar-context-form'); const permissionsForm = document.getElementById('orgs-permissions-form'); const dashboard = new window.LocalBase.components.OrganizationDashboard({ root: document.getElementById('orgsuite-admin'), @@ -38,6 +39,20 @@ return Object.fromEntries([...document.getElementById(containerId).querySelectorAll('input[type="checkbox"]')].map(input => [input.name, input.checked])); } + function renderCalendarContext(calendarContext) { + for (const field of ['countryCode', 'subdivisionCode', 'timezone']) { + const input = calendarContextForm.elements.namedItem(field); + if (input) input.value = calendarContext?.[field] || ''; + } + } + + function collectCalendarContext() { + return Object.fromEntries(['countryCode', 'subdivisionCode', 'timezone'].map(field => [ + field, + String(calendarContextForm.elements.namedItem(field)?.value || '').trim(), + ])); + } + function renderDirectoryStatus(directory) { const status = document.getElementById('orgs-directory-status'); const groups = document.getElementById('orgs-directory-groups'); @@ -70,6 +85,7 @@ async function load() { try { const data = await client.request('/api/ad-suite/admin/settings'); + renderCalendarContext(data.calendarContext); editor.set(data.organization, data.directory?.positions || [], data.dashboardLayout?.organigram?.zoom || 100); renderCheckboxes('orgs-calendar-peer-settings', data.calendarPeerEditing, data.calendarPeerOptions); renderCheckboxes('orgs-vacation-peer-settings', data.vacationPeerApproval, data.vacationPeerOptions); @@ -79,10 +95,25 @@ } catch (error) { notice.error(error); organizationForm.querySelector('button[type="submit"]').disabled = true; + calendarContextForm.querySelector('button[type="submit"]').disabled = true; permissionsForm.querySelector('button[type="submit"]').disabled = true; } } + calendarContextForm.addEventListener('submit', async event => { + event.preventDefault(); + try { + const data = await client.request('/api/ad-suite/admin/calendar-context', { + method: 'PUT', + body: JSON.stringify({ calendarContext: collectCalendarContext() }), + }); + renderCalendarContext(data.calendarContext); + notice.success('Gemeinsamer Kalenderkontext gespeichert.'); + } catch (error) { + notice.error(error); + } + }); + async function saveOrganization(organization) { try { await client.request('/api/ad-suite/admin/organization', { method: 'PUT', body: JSON.stringify({ organization }) }); diff --git a/lib/BackgroundJob/RefreshHolidayCalendarJob.php b/lib/BackgroundJob/RefreshHolidayCalendarJob.php new file mode 100644 index 0000000..4870a3e --- /dev/null +++ b/lib/BackgroundJob/RefreshHolidayCalendarJob.php @@ -0,0 +1,35 @@ +setInterval(24 * 3600); + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + $this->setAllowParallelRuns(false); + } + + #[Override] + protected function run($argument): void { + $year = (int)(new DateTimeImmutable('@' . $this->clock->getTime())) + ->setTimezone($this->contexts->context()->timezone()) + ->format('Y'); + for ($offset = 0; $offset <= 2; $offset++) $this->holidays->forYear($year + $offset, true); + } +} diff --git a/lib/Calendar/CalendarContext.php b/lib/Calendar/CalendarContext.php new file mode 100644 index 0000000..77add77 --- /dev/null +++ b/lib/Calendar/CalendarContext.php @@ -0,0 +1,87 @@ + self::VERSION]; + if (array_diff(array_keys($data), ['version', 'countryCode', 'subdivisionCode', 'timezone']) !== []) { + throw new InvalidArgumentException('Der Kalenderkontext enthält unbekannte Felder.'); + } + if ($data['version'] !== self::VERSION) { + throw new InvalidArgumentException('Die Kalenderkontext-Version wird nicht unterstützt.'); + } + + $countryCode = strtoupper(trim((string)($data['countryCode'] ?? ''))); + $subdivisionCode = strtoupper(trim((string)($data['subdivisionCode'] ?? ''))); + $timezoneName = trim((string)($data['timezone'] ?? '')); + if (preg_match('/^[A-Z]{2}$/', $countryCode) !== 1) { + throw new InvalidArgumentException('Der Ländercode muss aus zwei Buchstaben bestehen.'); + } + if (preg_match('/^' . preg_quote($countryCode, '/') . '-[A-Z0-9]{1,3}$/', $subdivisionCode) !== 1) { + throw new InvalidArgumentException('Der Regionscode muss zum Land passen und ISO 3166-2 entsprechen.'); + } + if (!self::isTimezone($timezoneName)) { + throw new InvalidArgumentException('Die fachliche Zeitzone ist keine gültige IANA-Zeitzone.'); + } + + return new self($countryCode, $subdivisionCode, $timezoneName); + } + + /** @return list */ + public static function get_all(array $items): array { + return array_map(static fn(array $item): self => self::get($item), $items); + } + + public static function defaults(): self { + return self::get([ + 'countryCode' => self::DEFAULT_COUNTRY, + 'subdivisionCode' => self::DEFAULT_SUBDIVISION, + 'timezone' => self::DEFAULT_TIMEZONE, + ]); + } + + public function countryCode(): string { return $this->countryCode; } + public function subdivisionCode(): string { return $this->subdivisionCode; } + public function timezone(): DateTimeZone { return new DateTimeZone($this->timezoneName); } + + /** @return array{version:int,countryCode:string,subdivisionCode:string,timezone:string} */ + public function toArray(): array { + return [ + 'version' => self::VERSION, + 'countryCode' => $this->countryCode, + 'subdivisionCode' => $this->subdivisionCode, + 'timezone' => $this->timezoneName, + ]; + } + + public function save(): never { + throw new LogicException('Kalenderkontexte werden über den Einstellungsservice gespeichert.'); + } + + private static function isTimezone(string $timezone): bool { + return $timezone === 'UTC' || in_array($timezone, DateTimeZone::listIdentifiers(DateTimeZone::ALL_WITH_BC), true); + } +} diff --git a/lib/Calendar/CalendarContextSettingsService.php b/lib/Calendar/CalendarContextSettingsService.php new file mode 100644 index 0000000..edfb7dc --- /dev/null +++ b/lib/Calendar/CalendarContextSettingsService.php @@ -0,0 +1,40 @@ +config->getValueString(Application::APP_ID, self::KEY, ''); + if ($raw === '') return CalendarContext::defaults(); + try { + $data = json_decode($raw, true, 16, JSON_THROW_ON_ERROR); + return CalendarContext::get(is_array($data) ? $data : []); + } catch (\Throwable) { + return CalendarContext::defaults(); + } + } + + public function save(array $data): CalendarContext { + $context = CalendarContext::get($data); + $this->config->setValueString( + Application::APP_ID, + self::KEY, + json_encode($context->toArray(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + ); + return $context; + } +} diff --git a/lib/Calendar/HolidayCalendar.php b/lib/Calendar/HolidayCalendar.php new file mode 100644 index 0000000..f3452ee --- /dev/null +++ b/lib/Calendar/HolidayCalendar.php @@ -0,0 +1,92 @@ + self::VERSION]; + if (array_diff(array_keys($data), ['version', 'year', 'context', 'fetchedAt', 'refreshAttemptedAt', 'source', 'schoolHolidays', 'publicHolidays', 'cacheStatus']) !== []) { + throw new InvalidArgumentException('Der Ferien- und Feiertagskalender enthält unbekannte Felder.'); + } + if ($data['version'] !== self::VERSION) throw new InvalidArgumentException('Die Kalenderdatenversion wird nicht unterstützt.'); + $year = $data['year'] ?? null; + if (!is_int($year) || $year < 2000 || $year > 2100) throw new InvalidArgumentException('Das Kalenderjahr ist ungültig.'); + $context = CalendarContext::get(is_array($data['context'] ?? null) ? $data['context'] : []); + $fetchedAt = self::timestamp($data['fetchedAt'] ?? null); + $refreshAttemptedAt = self::timestamp($data['refreshAttemptedAt'] ?? null); + $source = self::source($data['source'] ?? null); + $school = HolidayPeriod::get_all(self::periods($data['schoolHolidays'] ?? null, HolidayPeriod::TYPE_SCHOOL)); + $public = HolidayPeriod::get_all(self::periods($data['publicHolidays'] ?? null, HolidayPeriod::TYPE_PUBLIC)); + $status = (string)($data['cacheStatus'] ?? ''); + if (!in_array($status, self::STATUSES, true)) throw new InvalidArgumentException('Der Kalendercachestatus ist ungültig.'); + return new self($year, $context, $fetchedAt, $refreshAttemptedAt, $source, $school, $public, $status); + } + + public function withCacheStatus(string $status): self { + return self::get(array_replace($this->toArray(), ['cacheStatus' => $status])); + } + + public function year(): int { return $this->year; } + public function context(): CalendarContext { return $this->context; } + public function fetchedAt(): ?string { return $this->fetchedAt; } + public function refreshAttemptedAt(): ?string { return $this->refreshAttemptedAt; } + + public function toArray(): array { + return [ + 'version' => self::VERSION, + 'year' => $this->year, + 'context' => $this->context->toArray(), + 'fetchedAt' => $this->fetchedAt, + 'refreshAttemptedAt' => $this->refreshAttemptedAt, + 'source' => $this->source, + 'schoolHolidays' => array_map(static fn(HolidayPeriod $period): array => $period->toArray(), $this->schoolHolidays), + 'publicHolidays' => array_map(static fn(HolidayPeriod $period): array => $period->toArray(), $this->publicHolidays), + 'cacheStatus' => $this->cacheStatus, + ]; + } + + public function save(): never { throw new LogicException('Kalenderdaten werden ausschließlich über den gemeinsamen Cache gespeichert.'); } + + private static function timestamp(mixed $value): ?string { + if ($value === null) return null; + if (!is_string($value) || strlen($value) > 64) throw new InvalidArgumentException('Der Kalenderzeitstempel ist ungültig.'); + try { new DateTimeImmutable($value); } catch (\Throwable) { throw new InvalidArgumentException('Der Kalenderzeitstempel ist ungültig.'); } + return $value; + } + + private static function source(mixed $value): array { + if (!is_array($value) || array_diff(array_keys($value), ['name', 'url', 'license']) !== []) throw new InvalidArgumentException('Die Kalenderquelle ist ungültig.'); + $source = array_map(static fn(mixed $item): string => trim((string)$item), $value); + if ($source['name'] === '' || $source['license'] === '' || filter_var($source['url'], FILTER_VALIDATE_URL) === false || !str_starts_with(strtolower($source['url']), 'https://')) { + throw new InvalidArgumentException('Die Kalenderquelle ist ungültig.'); + } + return $source; + } + + private static function periods(mixed $value, string $type): array { + if (!is_array($value) || !array_is_list($value)) throw new InvalidArgumentException('Die Kalenderzeiträume sind ungültig.'); + return array_map(static fn(mixed $item): array => is_array($item) ? ['type' => $type] + $item : [], $value); + } +} diff --git a/lib/Calendar/HolidayCalendarCacheStore.php b/lib/Calendar/HolidayCalendarCacheStore.php new file mode 100644 index 0000000..66f6d1b --- /dev/null +++ b/lib/Calendar/HolidayCalendarCacheStore.php @@ -0,0 +1,38 @@ +config->getValueString(Application::APP_ID, $this->key($year, $context), ''); + if ($raw === '') return null; + try { + $data = json_decode($raw, true, 128, JSON_THROW_ON_ERROR); + $calendar = HolidayCalendar::get(is_array($data) ? $data : []); + return $calendar->year() === $year && $calendar->context()->toArray() === $context->toArray() ? $calendar : null; + } catch (\Throwable) { + return null; + } + } + + public function save(HolidayCalendar $calendar): void { + $this->config->setValueString( + Application::APP_ID, + $this->key($calendar->year(), $calendar->context()), + json_encode($calendar->toArray(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + ); + } + + private function key(int $year, CalendarContext $context): string { + $region = $context->countryCode() . '|' . $context->subdivisionCode(); + return 'holiday_calendar_' . substr(hash('sha256', $region), 0, 20) . '_' . $year; + } +} diff --git a/lib/Calendar/HolidayCalendarService.php b/lib/Calendar/HolidayCalendarService.php new file mode 100644 index 0000000..11aa987 --- /dev/null +++ b/lib/Calendar/HolidayCalendarService.php @@ -0,0 +1,85 @@ + 2100) throw new InvalidArgumentException('Ungültiges Kalenderjahr.'); + $context = $this->contexts->context(); + $cached = $this->cache->get($year, $context); + if (!$forceRefresh && $cached !== null && $this->isCurrent($cached)) return $cached->withCacheStatus('current'); + if (!$forceRefresh && $cached !== null && !$this->retryDue($cached)) return $cached->withCacheStatus($cached->fetchedAt() === null ? 'unavailable' : 'stale'); + + $now = $this->dateTime($this->time->getTime()); + try { + $remote = $this->provider->fetchYear($year, $context); + $calendar = HolidayCalendar::get([ + 'year' => $year, + 'context' => $context->toArray(), + 'fetchedAt' => $now, + 'refreshAttemptedAt' => $now, + 'source' => $this->source(), + 'schoolHolidays' => $remote['schoolHolidays'], + 'publicHolidays' => $remote['publicHolidays'], + 'cacheStatus' => 'fresh', + ]); + } catch (\Throwable $error) { + $this->logger->warning('Gemeinsame Ferien- und Feiertagsdaten konnten nicht aktualisiert werden.', [ + 'year' => $year, + 'subdivision' => $context->subdivisionCode(), + 'exception' => $error, + ]); + $fallback = $cached?->toArray() ?? [ + 'year' => $year, + 'context' => $context->toArray(), + 'fetchedAt' => null, + 'source' => $this->source(), + 'schoolHolidays' => [], + 'publicHolidays' => [], + ]; + $fallback['refreshAttemptedAt'] = $now; + $fallback['cacheStatus'] = $cached !== null ? 'stale' : 'unavailable'; + $calendar = HolidayCalendar::get($fallback); + } + $this->cache->save($calendar); + return $calendar; + } + + private function isCurrent(HolidayCalendar $calendar): bool { + $fetchedAt = $calendar->fetchedAt() === null ? false : strtotime($calendar->fetchedAt()); + return $fetchedAt !== false && $fetchedAt >= $this->time->getTime() - self::CACHE_TTL_SECONDS; + } + + private function retryDue(HolidayCalendar $calendar): bool { + $attemptedAt = $calendar->refreshAttemptedAt() === null ? false : strtotime($calendar->refreshAttemptedAt()); + return $attemptedAt === false || $attemptedAt < $this->time->getTime() - self::FAILURE_RETRY_SECONDS; + } + + private function dateTime(int $timestamp): string { + return (new DateTimeImmutable('@' . $timestamp))->setTimezone(new DateTimeZone('UTC'))->format(DATE_ATOM); + } + + private function source(): array { + return ['name' => OpenHolidaysClient::SOURCE_NAME, 'url' => OpenHolidaysClient::SOURCE_URL, 'license' => OpenHolidaysClient::SOURCE_LICENSE]; + } +} diff --git a/lib/Calendar/HolidayPeriod.php b/lib/Calendar/HolidayPeriod.php new file mode 100644 index 0000000..3f91204 --- /dev/null +++ b/lib/Calendar/HolidayPeriod.php @@ -0,0 +1,60 @@ + 320) throw new InvalidArgumentException('Der Kalenderzeitraum besitzt keinen gültigen Namen.'); + if ($endDate < $startDate) throw new InvalidArgumentException('Der Kalenderzeitraum endet vor seinem Beginn.'); + return new self($type, $name, $startDate, $endDate); + } + + /** @return list */ + public static function get_all(array $items): array { + return array_map(static fn(array $item): self => self::get($item), $items); + } + + public function type(): string { return $this->type; } + + /** @return array{type:string,name:string,startDate:string,endDate:string} */ + public function toArray(): array { + return ['type' => $this->type, 'name' => $this->name, 'startDate' => $this->startDate, 'endDate' => $this->endDate]; + } + + public function save(): never { throw new LogicException('Kalenderzeiträume sind read-only Providerdaten.'); } + + private static function date(mixed $value): string { + if (!is_string($value) || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) throw new InvalidArgumentException('Der Kalenderzeitraum besitzt ein ungültiges Datum.'); + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + if ($date === false || $date->format('Y-m-d') !== $value) throw new InvalidArgumentException('Der Kalenderzeitraum besitzt ein ungültiges Datum.'); + return $value; + } + + private static function length(string $value): int { return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); } +} diff --git a/lib/Calendar/OpenHolidaysClient.php b/lib/Calendar/OpenHolidaysClient.php new file mode 100644 index 0000000..454798b --- /dev/null +++ b/lib/Calendar/OpenHolidaysClient.php @@ -0,0 +1,85 @@ + 2100) throw new RuntimeException('Ungültiges Kalenderjahr.'); + $query = [ + 'countryIsoCode' => $context->countryCode(), + 'subdivisionCode' => $context->subdivisionCode(), + 'languageIsoCode' => 'DE', + 'validFrom' => sprintf('%04d-01-01', $year), + 'validTo' => sprintf('%04d-12-31', $year), + ]; + return [ + 'schoolHolidays' => $this->request('SchoolHolidays', 'School', HolidayPeriod::TYPE_SCHOOL, $query), + 'publicHolidays' => $this->request('PublicHolidays', 'Public', HolidayPeriod::TYPE_PUBLIC, $query), + ]; + } + + private function request(string $endpoint, string $expectedType, string $type, array $query): array { + $url = self::API_URL . '/' . $endpoint . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); + try { + $response = $this->clients->newClient()->get($url, ['headers' => ['Accept' => 'application/json'], 'timeout' => 10]); + } catch (\Throwable $error) { + throw new RuntimeException('OpenHolidays API ist nicht erreichbar.', 0, $error); + } + if ($response->getStatusCode() !== 200) throw new RuntimeException('OpenHolidays API hat unerwartet geantwortet.'); + $body = $response->getBody(); + if (is_resource($body)) $body = stream_get_contents($body) ?: ''; + if (!is_string($body) || strlen($body) > self::MAX_RESPONSE_BYTES) throw new RuntimeException('OpenHolidays API lieferte eine ungültige Antwortgröße.'); + try { $decoded = json_decode($body, true, 128, JSON_THROW_ON_ERROR); } + catch (\JsonException $error) { throw new RuntimeException('OpenHolidays API lieferte ungültiges JSON.', 0, $error); } + if (!is_array($decoded) || !array_is_list($decoded)) throw new RuntimeException('OpenHolidays API lieferte ein ungültiges Datenformat.'); + + $periods = []; + foreach ($decoded as $item) { + if (!is_array($item) || ($item['type'] ?? null) !== $expectedType) throw new RuntimeException('OpenHolidays API lieferte einen unerwarteten Kalendertyp.'); + try { + $periods[] = HolidayPeriod::get([ + 'type' => $type, + 'name' => $this->germanName($item['name'] ?? null), + 'startDate' => $this->date($item['startDate'] ?? null), + 'endDate' => $this->date($item['endDate'] ?? null), + ])->toArray(); + } catch (\InvalidArgumentException $error) { + throw new RuntimeException('OpenHolidays API lieferte ein ungültiges Datum oder Zeitintervall.', 0, $error); + } + } + usort($periods, static fn(array $left, array $right): int => [$left['startDate'], $left['endDate'], $left['name']] <=> [$right['startDate'], $right['endDate'], $right['name']]); + return $periods; + } + + private function date(mixed $value): string { + if (!is_string($value) || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) throw new \InvalidArgumentException('ungültiges Datum'); + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + if ($date === false || $date->format('Y-m-d') !== $value) throw new \InvalidArgumentException('ungültiges Datum'); + return $value; + } + + private function germanName(mixed $names): string { + if (!is_array($names)) throw new RuntimeException('OpenHolidays API lieferte keinen Namen.'); + foreach ($names as $name) { + if (!is_array($name) || strtoupper((string)($name['language'] ?? '')) !== 'DE') continue; + $text = trim((string)($name['text'] ?? '')); + if ($text !== '' && (function_exists('mb_strlen') ? mb_strlen($text) : strlen($text)) <= 320) return $text; + } + throw new RuntimeException('OpenHolidays API lieferte keinen deutschen Namen.'); + } +} diff --git a/lib/Controller/AdSuiteAdminApiController.php b/lib/Controller/AdSuiteAdminApiController.php index 84241d2..348a898 100644 --- a/lib/Controller/AdSuiteAdminApiController.php +++ b/lib/Controller/AdSuiteAdminApiController.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use OCA\LocalBase\AppInfo\Application; +use OCA\LocalBase\Calendar\CalendarContextSettingsService; use OCA\LocalBase\Organization\AdOrganizationSettingsService; use OCA\LocalBase\Organization\AdSuiteAdminSettingsService; use OCA\LocalBase\Service\AdSuiteAdminLayoutService; @@ -30,6 +31,7 @@ public function __construct( private IGroupManager $groups, private AdOrganizationSettingsService $organization, private AdSuiteAdminSettingsService $adminSettings, + private CalendarContextSettingsService $calendarContext, private OrganizationDirectoryStatusService $directoryStatus, private AdSuiteAdminLayoutService $dashboardLayout, private LoggerInterface $logger, @@ -41,6 +43,7 @@ public function settings(): JSONResponse { if (!$this->isAdmin()) return $this->denied(); return new JSONResponse([ 'organization' => $this->organization->definition()->toArray(), + 'calendarContext' => $this->calendarContext->context()->toArray(), 'calendarPeerEditing' => $this->adminSettings->calendarPeerEditing(), 'calendarPeerOptions' => $this->adminSettings->calendarPeerOptions(), 'vacationPeerApproval' => $this->adminSettings->vacationPeerApproval(), @@ -50,6 +53,18 @@ public function settings(): JSONResponse { ]); } + public function saveCalendarContext(array $calendarContext): JSONResponse { + if (!$this->isAdmin()) return $this->denied(); + try { + return new JSONResponse(['calendarContext' => $this->calendarContext->save($calendarContext)->toArray()]); + } catch (InvalidArgumentException $error) { + return new JSONResponse(['error' => $error->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (\Throwable $error) { + $this->logger->error('Gemeinsamer Kalenderkontext konnte nicht gespeichert werden.', ['exception' => $error]); + return new JSONResponse(['error' => 'Der gemeinsame Kalenderkontext konnte nicht gespeichert werden.'], Http::STATUS_BAD_REQUEST); + } + } + public function saveOrganization(array $organization): JSONResponse { if (!$this->isAdmin()) return $this->denied(); try { diff --git a/lib/Migration/Version000001Date202607220001.php b/lib/Migration/Version000001Date202607220001.php new file mode 100644 index 0000000..ff26359 --- /dev/null +++ b/lib/Migration/Version000001Date202607220001.php @@ -0,0 +1,22 @@ +jobs->has(RefreshHolidayCalendarJob::class, null)) { + $this->jobs->add(RefreshHolidayCalendarJob::class); + } + } +} diff --git a/lib/Service/AdSuiteAdminLayoutService.php b/lib/Service/AdSuiteAdminLayoutService.php index 9122a92..04f3815 100644 --- a/lib/Service/AdSuiteAdminLayoutService.php +++ b/lib/Service/AdSuiteAdminLayoutService.php @@ -18,7 +18,7 @@ final class AdSuiteAdminLayoutService { private const CONFIG_KEY = 'ad_suite_admin_dashboard_layout'; private const VERSION = 1; private const BLOCKS = [ - 'main' => ['directory', 'organization', 'permissions'], + 'main' => ['directory', 'calendar-context', 'organization', 'permissions'], 'organization' => ['general', 'hierarchy', 'role-order', 'areas', 'vacation-views'], 'permissions' => ['calendar-permissions', 'vacation-permissions'], ]; diff --git a/templates/organization-admin.php b/templates/organization-admin.php index 5de6656..2da29e3 100644 --- a/templates/organization-admin.php +++ b/templates/organization-admin.php @@ -24,6 +24,30 @@ +
+

Kalenderregion und fachliche Zeitzone

+
+

Dieser organisationsweite Kontext bestimmt gemeinsame Ferien, Feiertage und fachliche Kalendertage. Persönliche Nextcloud-Zeitzonen verändern ausschließlich die individuelle Terminanzeige.

+
+
+ + + +
+ +
+
+
+

AD-Organisation

diff --git a/tests/Controller/AdSuiteAdminApiControllerExecutionTest.php b/tests/Controller/AdSuiteAdminApiControllerExecutionTest.php index 0135c9a..b7c0d2b 100644 --- a/tests/Controller/AdSuiteAdminApiControllerExecutionTest.php +++ b/tests/Controller/AdSuiteAdminApiControllerExecutionTest.php @@ -21,6 +21,19 @@ public function getStatus(): int { return $this->status; } } namespace Psr\Log { interface LoggerInterface { public function error(string $message, array $context = []): void; } } namespace OCA\LocalBase\AppInfo { final class Application { public const APP_ID = 'localbase'; } } +namespace OCA\LocalBase\Calendar { + class Context { public function __construct(private array $data) {} public function toArray(): array { return $this->data; } } + class CalendarContextSettingsService { + public bool $invalid = false; + public bool $failure = false; + public function context(): Context { return new Context(['countryCode' => 'DE', 'subdivisionCode' => 'DE-BE', 'timezone' => 'Europe/Berlin']); } + public function save(array $data): Context { + if ($this->invalid) throw new \InvalidArgumentException('Ungültiger Kalenderkontext.'); + if ($this->failure) throw new \RuntimeException('Intern'); + return new Context($data); + } + } +} namespace OCA\LocalBase\Organization { class Definition { public function __construct(private array $data) {} public function toArray(): array { return $this->data; } } class AdOrganizationSettingsService { @@ -65,6 +78,7 @@ public function status(): array { return ['compatible' => true, 'demoWritable' = use OCA\LocalBase\Organization\AdOrganizationSettingsService; use OCA\LocalBase\Organization\AdSuiteAdminSettingsService; + use OCA\LocalBase\Calendar\CalendarContextSettingsService; use OCA\LocalBase\Controller\AdSuiteAdminApiController; use OCA\LocalBase\Service\AdSuiteAdminLayoutService; use OCA\LocalBase\Service\OrganizationDirectoryStatusService; @@ -80,15 +94,22 @@ public function status(): array { return ['compatible' => true, 'demoWritable' = $groups = new class implements IGroupManager { public bool $admin = false; public function isAdmin(string $uid): bool { return $this->admin; } }; $organization = new AdOrganizationSettingsService(); $settings = new AdSuiteAdminSettingsService(); + $calendarContext = new CalendarContextSettingsService(); $logger = new class implements LoggerInterface { public array $errors = []; public function error(string $message, array $context = []): void { $this->errors[] = [$message, $context]; } }; $directory = new OrganizationDirectoryStatusService(); $layout = new AdSuiteAdminLayoutService(); - $controller = new AdSuiteAdminApiController($request, $session, $groups, $organization, $settings, $directory, $layout, $logger); + $controller = new AdSuiteAdminApiController($request, $session, $groups, $organization, $settings, $calendarContext, $directory, $layout, $logger); if ($controller->settings()->getStatus() !== 403) throw new RuntimeException('Nicht-Admin kann Einstellungen lesen.'); - if ($controller->saveOrganization([])->getStatus() !== 403 || $controller->savePermissions([], [])->getStatus() !== 403 || $controller->saveLayout([])->getStatus() !== 403) throw new RuntimeException('Nicht-Admin kann Einstellungen schreiben.'); + if ($controller->saveCalendarContext([])->getStatus() !== 403 || $controller->saveOrganization([])->getStatus() !== 403 || $controller->savePermissions([], [])->getStatus() !== 403 || $controller->saveLayout([])->getStatus() !== 403) throw new RuntimeException('Nicht-Admin kann Einstellungen schreiben.'); $groups->admin = true; $data = $controller->settings()->getData(); - if (($data['organization']['roles'][0] ?? '') !== 'buero' || !isset($data['calendarPeerOptions'], $data['vacationPeerOptions']) || ($data['directory']['compatible'] ?? null) !== true || ($data['directory']['positions'][0]['displayNames'] ?? []) !== ['Gina Führung'] || ($data['dashboardLayout']['version'] ?? null) !== 1) throw new RuntimeException('Admin-Einstellungen sind unvollständig.'); + if (($data['organization']['roles'][0] ?? '') !== 'buero' || ($data['calendarContext']['subdivisionCode'] ?? '') !== 'DE-BE' || !isset($data['calendarPeerOptions'], $data['vacationPeerOptions']) || ($data['directory']['compatible'] ?? null) !== true || ($data['directory']['positions'][0]['displayNames'] ?? []) !== ['Gina Führung'] || ($data['dashboardLayout']['version'] ?? null) !== 1) throw new RuntimeException('Admin-Einstellungen sind unvollständig.'); + if ($controller->saveCalendarContext(['countryCode' => 'FR', 'subdivisionCode' => 'FR-IDF', 'timezone' => 'Europe/Paris'])->getData()['calendarContext']['timezone'] !== 'Europe/Paris') throw new RuntimeException('Kalenderkontext wird nicht gespeichert.'); + $calendarContext->invalid = true; + if ($controller->saveCalendarContext([])->getStatus() !== 400) throw new RuntimeException('Ungültiger Kalenderkontext erhält keinen Status 400.'); + $calendarContext->invalid = false; + $calendarContext->failure = true; + if ($controller->saveCalendarContext([])->getStatus() !== 400 || $logger->errors === []) throw new RuntimeException('Interner Kalenderkontextfehler wird nicht sicher behandelt.'); if ($controller->saveOrganization(['roles' => ['pfk']])->getData()['organization']['roles'][0] !== 'pfk') throw new RuntimeException('Organisation wird nicht gespeichert.'); $organization->invalid = true; if ($controller->saveOrganization([])->getStatus() !== 400) throw new RuntimeException('Validierungsfehler erhält keinen Status 400.'); diff --git a/tests/Controller/AdSuiteAdminContractTest.php b/tests/Controller/AdSuiteAdminContractTest.php index 706b6d7..6106885 100644 --- a/tests/Controller/AdSuiteAdminContractTest.php +++ b/tests/Controller/AdSuiteAdminContractTest.php @@ -11,12 +11,12 @@ foreach ([$routes, $application, $controller, $template, $info] as $source) if ($source === false) throw new RuntimeException('Organisationsvertrag konnte nicht gelesen werden.'); if (in_array((string)$info->version, ['0.7.0-rc.2', '0.7.0-rc.3', '0.7.0-rc.4', '0.7.0-rc.5', '0.7.0-rc.6'], true)) throw new RuntimeException('Geänderte Admin-Assets verwenden weiterhin einen bereits langfristig gecachten App-Versionsschlüssel.'); -foreach (['/api/ad-suite/admin/settings', '/api/ad-suite/admin/organization', '/api/ad-suite/admin/permissions', '/api/ad-suite/admin/layout'] as $contract) if (!str_contains($routes, $contract)) throw new RuntimeException("Admin-Route fehlt: {$contract}"); +foreach (['/api/ad-suite/admin/settings', '/api/ad-suite/admin/calendar-context', '/api/ad-suite/admin/organization', '/api/ad-suite/admin/permissions', '/api/ad-suite/admin/layout'] as $contract) if (!str_contains($routes, $contract)) throw new RuntimeException("Admin-Route fehlt: {$contract}"); if (preg_match('/#\[[^\]]*NoAdminRequired/', $controller)) throw new RuntimeException('Admin-Controller ist für normale Nutzer*innen freigegeben.'); if (preg_match('/#\[[^\]]*NoCSRFRequired[^\]]*\]\s+public function save/', $controller)) throw new RuntimeException('Schreibender Admin-Endpunkt umgeht CSRF.'); -foreach (['private function isAdmin()', '$this->groups->isAdmin(', 'Http::STATUS_FORBIDDEN', 'saveOrganization', 'savePermissions', 'saveLayout', 'dashboardLayout'] as $contract) if (!str_contains($controller, $contract)) throw new RuntimeException("Serverseitiger Admin-Vertrag fehlt: {$contract}"); +foreach (['private function isAdmin()', '$this->groups->isAdmin(', 'Http::STATUS_FORBIDDEN', 'saveCalendarContext', 'saveOrganization', 'savePermissions', 'saveLayout', 'calendarContext', 'dashboardLayout'] as $contract) if (!str_contains($controller, $contract)) throw new RuntimeException("Serverseitiger Admin-Vertrag fehlt: {$contract}"); foreach (['registerSection(IManager::SETTINGS_ADMIN', 'StandaloneProductAdminSection::class', 'StandaloneOrganizationAdmin::class'] as $contract) if (!str_contains($application, $contract)) throw new RuntimeException("Dynamische Adminregistrierung fehlt: {$contract}"); -foreach (['id="orgsuite-admin"', 'id="orgs-organization-form"', 'id="orgs-permissions-form"', 'Bei einer Einzelinstallation'] as $contract) if (!str_contains($template, $contract)) throw new RuntimeException("Admin-UI-Vertrag fehlt: {$contract}"); +foreach (['id="orgsuite-admin"', 'id="orgs-calendar-context-form"', 'id="orgs-calendar-country"', 'id="orgs-calendar-subdivision"', 'id="orgs-calendar-timezone"', 'id="orgs-organization-form"', 'id="orgs-permissions-form"', 'Bei einer Einzelinstallation'] as $contract) if (!str_contains($template, $contract)) throw new RuntimeException("Admin-UI-Vertrag fehlt: {$contract}"); foreach (["addScript('localbase', 'components/hierarchy-board')", "addScript('localbase', 'components/organization-editor')", "addScript('localbase', 'components/organization-dashboard')", "addStyle('localbase', 'organization-admin')"] as $contract) if (!str_contains($template, $contract)) throw new RuntimeException("Admin-Assetvertrag fehlt: {$contract}"); echo "AdSuiteAdminContractTest: OK\n"; diff --git a/tests/Service/AdSuiteAdminLayoutServiceSmokeTest.php b/tests/Service/AdSuiteAdminLayoutServiceSmokeTest.php index b7e4497..e63611e 100644 --- a/tests/Service/AdSuiteAdminLayoutServiceSmokeTest.php +++ b/tests/Service/AdSuiteAdminLayoutServiceSmokeTest.php @@ -33,7 +33,7 @@ public function setValueArray(string $userId, string $app, string $key, array $v $service = new AdSuiteAdminLayoutService($config, $logger); $default = $service->layout('admin-a'); if (($default['version'] ?? null) !== 1) throw new RuntimeException('Persönliches Adminlayout besitzt keine Vertragsversion.'); - if (($default['scopes']['main']['order'] ?? []) !== ['directory', 'organization', 'permissions']) throw new RuntimeException('Hauptblöcke fehlen im Standardlayout.'); + if (($default['scopes']['main']['order'] ?? []) !== ['directory', 'calendar-context', 'organization', 'permissions']) throw new RuntimeException('Hauptblöcke fehlen im Standardlayout.'); if (($default['scopes']['organization']['order'] ?? []) !== ['general', 'hierarchy', 'role-order', 'areas', 'vacation-views']) throw new RuntimeException('Organisationsblöcke fehlen im Standardlayout.'); if (($default['scopes']['permissions']['order'] ?? []) !== ['calendar-permissions', 'vacation-permissions']) throw new RuntimeException('Rechteblöcke fehlen im Standardlayout.'); if (($default['organigram']['zoom'] ?? null) !== 100) throw new RuntimeException('Das persönliche Standardlayout besitzt keinen neutralen Organigramm-Zoom.'); @@ -47,7 +47,7 @@ public function setValueArray(string $userId, string $app, string $key, array $v ], 'organigram' => ['zoom' => 130], ]); - if (($saved['scopes']['main']['order'][0] ?? '') !== 'permissions' || ($saved['scopes']['main']['collapsed'] ?? []) !== ['directory']) throw new RuntimeException('Persönliche Hauptansicht wird nicht gespeichert.'); + if (($saved['scopes']['main']['order'] ?? []) !== ['permissions', 'directory', 'organization', 'calendar-context'] || ($saved['scopes']['main']['collapsed'] ?? []) !== ['directory']) throw new RuntimeException('Persönliche Hauptansicht wird nicht gespeichert oder erhält den neuen Kalenderblock nicht additiv.'); if (($saved['scopes']['organization']['order'] ?? []) !== ['hierarchy', 'general', 'role-order', 'areas', 'vacation-views']) throw new RuntimeException('Neue oder ausgelassene Blöcke werden nicht sicher ergänzt.'); if (($saved['organigram']['zoom'] ?? null) !== 130) throw new RuntimeException('Persönlicher Organigramm-Zoom wird nicht gespeichert.'); if (($service->save('admin-d', ['scopes' => []])['organigram']['zoom'] ?? null) !== 100) throw new RuntimeException('Bestehende persönliche Layouts erhalten keinen rückwärtskompatiblen Standardzoom.'); diff --git a/tests/Service/CalendarContextSettingsServiceSmokeTest.php b/tests/Service/CalendarContextSettingsServiceSmokeTest.php new file mode 100644 index 0000000..c9f3a5b --- /dev/null +++ b/tests/Service/CalendarContextSettingsServiceSmokeTest.php @@ -0,0 +1,80 @@ +values[$appId][$key] ?? $default; } + public function setValueString(string $appId, string $key, string $value): void { $this->values[$appId][$key] = $value; } + }; + $service = new CalendarContextSettingsService($config); + + $default = $service->context(); + if ($default->toArray() !== [ + 'version' => 1, + 'countryCode' => 'DE', + 'subdivisionCode' => 'DE-BE', + 'timezone' => 'Europe/Berlin', + ]) throw new RuntimeException('Der Kalenderkontext besitzt nicht den freigegebenen Berlin-Bestandsdefault.'); + + $saved = $service->save([ + 'countryCode' => 'fr', + 'subdivisionCode' => 'fr-idf', + 'timezone' => 'Europe/Paris', + ]); + if ($saved->countryCode() !== 'FR' || $saved->subdivisionCode() !== 'FR-IDF' || $saved->timezone()->getName() !== 'Europe/Paris') { + throw new RuntimeException('Der administrative Kalenderkontext wird nicht normalisiert und persistiert.'); + } + if ($service->context()->toArray() !== $saved->toArray()) throw new RuntimeException('Der Kalenderkontext wird nicht gemeinsam aus LocalBase gelesen.'); + + foreach ([ + ['countryCode' => 'D', 'subdivisionCode' => 'DE-BE', 'timezone' => 'Europe/Berlin'], + ['countryCode' => 'FR', 'subdivisionCode' => 'DE-BE', 'timezone' => 'Europe/Paris'], + ['countryCode' => 'DE', 'subdivisionCode' => 'DE-BERLIN', 'timezone' => 'Europe/Berlin'], + ['countryCode' => 'DE', 'subdivisionCode' => 'DE-BE', 'timezone' => 'Mars/Olympus'], + ['countryCode' => 'DE', 'subdivisionCode' => 'DE-BE', 'timezone' => 'Europe/Berlin', 'personalTimezone' => 'UTC'], + ] as $invalid) { + try { + $service->save($invalid); + throw new RuntimeException('Ein ungültiger Kalenderkontext wurde gespeichert.'); + } catch (InvalidArgumentException) { + } + } + if ($service->context()->toArray() !== $saved->toArray()) throw new RuntimeException('Ein abgelehnter Kalenderkontext verändert den letzten gültigen Stand.'); + + try { + $saved->save(); + throw new RuntimeException('Der read-only Kalenderkontext kann unerwartet direkt persistiert werden.'); + } catch (LogicException) { + } + + $config->values['localbase']['calendar_context'] = '{kaputt'; + if ($service->context()->toArray() !== CalendarContext::defaults()->toArray()) { + throw new RuntimeException('Ungültige Kalenderkontext-Persistenz fällt nicht sicher auf den Bestandsdefault zurück.'); + } + + echo "CalendarContextSettingsServiceSmokeTest: OK\n"; +} diff --git a/tests/Service/HolidayCalendarServiceSmokeTest.php b/tests/Service/HolidayCalendarServiceSmokeTest.php new file mode 100644 index 0000000..3bc8dd5 --- /dev/null +++ b/tests/Service/HolidayCalendarServiceSmokeTest.php @@ -0,0 +1,160 @@ +body, JSON_THROW_ON_ERROR); } + public function getStatusCode(): int { return 200; } + } + final class SharedHolidayHttpClient implements IClient { + public array $requests = []; + public bool $fail = false; + public bool $invalid = false; + public function get(string $url, array $options = []): IResponse { + $this->requests[] = [$url, $options]; + if ($this->fail) throw new RuntimeException('synthetischer Netzfehler'); + $school = str_contains($url, '/SchoolHolidays?'); + return new SharedHolidayResponse($school ? [[ + 'startDate' => $this->invalid ? '02.02.2026' : '2026-02-02', + 'endDate' => '2026-02-07', + 'type' => 'School', + 'name' => [['language' => 'DE', 'text' => 'Winterferien']], + ]] : [[ + 'startDate' => '2026-03-08', + 'endDate' => '2026-03-08', + 'type' => 'Public', + 'name' => [['language' => 'DE', 'text' => 'Internationaler Frauentag']], + ]]); + } + } + + $config = new class implements IAppConfig { + public array $values = []; + public array $writes = []; + public function getValueString(string $appId, string $key, string $default = ''): string { return $this->values[$appId][$key] ?? $default; } + public function setValueString(string $appId, string $key, string $value): void { $this->values[$appId][$key] = $value; $this->writes[] = [$appId, $key]; } + }; + $http = new SharedHolidayHttpClient(); + $clients = new class($http) implements IClientService { + public function __construct(private IClient $client) {} + public function newClient(): IClient { return $this->client; } + }; + $now = strtotime('2026-07-22T12:00:00Z'); + $time = new class($now) implements ITimeFactory { + public function __construct(public int $now) {} + public function getTime(): int { return $this->now; } + }; + $logger = new class implements LoggerInterface { + public array $warnings = []; + public function warning(string $message, array $context = []): void { $this->warnings[] = [$message, $context]; } + }; + $contexts = new CalendarContextSettingsService($config); + $service = new HolidayCalendarService( + new OpenHolidaysClient($clients), + new HolidayCalendarCacheStore($config), + $contexts, + $time, + $logger, + ); + + $fresh = $service->forYear(2026)->toArray(); + if (($fresh['cacheStatus'] ?? '') !== 'fresh' + || ($fresh['schoolHolidays'][0]['name'] ?? '') !== 'Winterferien' + || ($fresh['publicHolidays'][0]['name'] ?? '') !== 'Internationaler Frauentag' + || ($fresh['context']['subdivisionCode'] ?? '') !== 'DE-BE') { + throw new RuntimeException('Der gemeinsame Erstabruf ist nicht vollständig und frisch.'); + } + if (count($http->requests) !== 2) throw new RuntimeException('OpenHolidays wird nicht genau einmal je Datentyp geladen.'); + foreach ($http->requests as [$url, $options]) { + if (!str_contains($url, 'countryIsoCode=DE') + || !str_contains($url, 'subdivisionCode=DE-BE') + || !str_contains($url, 'languageIsoCode=DE') + || ($options['timeout'] ?? null) !== 10 + || ($options['headers']['Accept'] ?? '') !== 'application/json') { + throw new RuntimeException('OpenHolidays erhält nicht den validierten Kontext und die sicheren HTTP-Grenzen.'); + } + } + $cacheWrites = array_values(array_filter($config->writes, static fn(array $write): bool => str_starts_with($write[1], 'holiday_calendar_'))); + if (count($cacheWrites) !== 1 || $cacheWrites[0][0] !== 'localbase') throw new RuntimeException('Der gemeinsame Kalendercache liegt nicht eindeutig in LocalBase.'); + + if ($service->forYear(2026)->toArray()['cacheStatus'] !== 'current' || count($http->requests) !== 2) { + throw new RuntimeException('Ein aktueller gemeinsamer Cache löst unnötige Provideranfragen aus.'); + } + $time->now += 25 * 3600; + $http->fail = true; + $stale = $service->forYear(2026)->toArray(); + if ($stale['cacheStatus'] !== 'stale' || $stale['schoolHolidays'][0]['name'] !== 'Winterferien' || $logger->warnings === []) { + throw new RuntimeException('Der letzte gültige gemeinsame Cache bleibt bei Ausfall nicht verfügbar.'); + } + $requestsAfterFailure = count($http->requests); + if ($service->forYear(2026)->toArray()['cacheStatus'] !== 'stale' || count($http->requests) !== $requestsAfterFailure) { + throw new RuntimeException('Die Rückoffzeit nach einem Providerfehler wird nicht eingehalten.'); + } + $unavailable = $service->forYear(2027)->toArray(); + if ($unavailable['cacheStatus'] !== 'unavailable' || $unavailable['schoolHolidays'] !== [] || $unavailable['publicHolidays'] !== []) { + throw new RuntimeException('Ein Erstfehler wird nicht transparent als leerer Ausfall ausgeliefert.'); + } + + $contexts->save(['countryCode' => 'FR', 'subdivisionCode' => 'FR-IDF', 'timezone' => 'Europe/Paris']); + $http->fail = false; + $requestsBeforeRegionChange = count($http->requests); + $service->forYear(2026); + if (count($http->requests) !== $requestsBeforeRegionChange + 2) throw new RuntimeException('Eine neue Region verwendet unerwartet den Cache der vorherigen Region.'); + foreach (array_slice($http->requests, -2) as [$url]) { + if (!str_contains($url, 'countryIsoCode=FR') || !str_contains($url, 'subdivisionCode=FR-IDF')) { + throw new RuntimeException('Der Provider erhält nach einer Adminänderung nicht den neuen Kontext.'); + } + } + + $http->invalid = true; + try { + (new OpenHolidaysClient($clients))->fetchYear(2028, $contexts->context()); + throw new RuntimeException('Ungültige externe Datumswerte werden akzeptiert.'); + } catch (RuntimeException $error) { + if (!str_contains($error->getMessage(), 'ungültiges Datum')) throw $error; + } + + echo "HolidayCalendarServiceSmokeTest: OK\n"; +} diff --git a/tests/Service/RefreshHolidayCalendarJobSmokeTest.php b/tests/Service/RefreshHolidayCalendarJobSmokeTest.php new file mode 100644 index 0000000..6a70b66 --- /dev/null +++ b/tests/Service/RefreshHolidayCalendarJobSmokeTest.php @@ -0,0 +1,56 @@ +interval = $interval; } + protected function setTimeSensitivity(int $sensitivity): void { $this->sensitivity = $sensitivity; } + protected function setAllowParallelRuns(bool $parallel): void { $this->parallel = $parallel; } + abstract protected function run($argument): void; + } +} +namespace OCA\LocalBase\Calendar { + class Context { public function timezone(): \DateTimeZone { return new \DateTimeZone('Europe/Berlin'); } } + class CalendarContextSettingsService { public function context(): Context { return new Context(); } } + class HolidayCalendarService { + public array $calls = []; + public function forYear(int $year, bool $forceRefresh = false): void { $this->calls[] = [$year, $forceRefresh]; } + } +} + +namespace { + require_once __DIR__ . '/../../lib/BackgroundJob/RefreshHolidayCalendarJob.php'; + + use OCA\LocalBase\BackgroundJob\RefreshHolidayCalendarJob; + use OCA\LocalBase\Calendar\CalendarContextSettingsService; + use OCA\LocalBase\Calendar\HolidayCalendarService; + use OCP\AppFramework\Utility\ITimeFactory; + use OCP\BackgroundJob\IJob; + + $clock = new class implements ITimeFactory { + public function getTime(): int { return strtotime('2026-12-31T23:30:00Z'); } + }; + $holidays = new HolidayCalendarService(); + $job = new RefreshHolidayCalendarJob($clock, new CalendarContextSettingsService(), $holidays); + if ($job->interval !== 24 * 3600 || $job->sensitivity !== IJob::TIME_INSENSITIVE || $job->parallel !== false) { + throw new RuntimeException('Der gemeinsame Kalenderjob besitzt nicht den sicheren Zeitvertrag.'); + } + $run = new ReflectionMethod($job, 'run'); + $run->invoke($job, null); + if ($holidays->calls !== [[2027, true], [2028, true], [2029, true]]) { + throw new RuntimeException('Der gemeinsame Kalenderjob verwendet nicht die Fachzeitzone und zwei Folgejahre.'); + } + + $info = file_get_contents(__DIR__ . '/../../appinfo/info.xml'); + if ($info === false || !str_contains($info, 'OCA\\LocalBase\\BackgroundJob\\RefreshHolidayCalendarJob')) { + throw new RuntimeException('Der gemeinsame Kalenderjob ist nicht in der App registriert.'); + } + echo "RefreshHolidayCalendarJobSmokeTest: OK\n"; +} diff --git a/tests/Service/RefreshHolidayCalendarMigrationSmokeTest.php b/tests/Service/RefreshHolidayCalendarMigrationSmokeTest.php new file mode 100644 index 0000000..c6bdf90 --- /dev/null +++ b/tests/Service/RefreshHolidayCalendarMigrationSmokeTest.php @@ -0,0 +1,38 @@ +entries[$job]); } + public function add(string $job, $argument = null): void { $this->entries[$job] = $argument; } + }; + $migration = new Version000001Date202607220001($jobs); + $migration->postSchemaChange(new class implements IOutput {}, static fn() => null, []); + $migration->postSchemaChange(new class implements IOutput {}, static fn() => null, []); + if (array_keys($jobs->entries) !== [RefreshHolidayCalendarJob::class]) { + throw new RuntimeException('Der gemeinsame Kalenderjob wird bei Updates nicht idempotent registriert.'); + } + + echo "RefreshHolidayCalendarMigrationSmokeTest: OK\n"; +} diff --git a/tests/js/organization-admin-smoke.mjs b/tests/js/organization-admin-smoke.mjs index 78bcedf..9468942 100644 --- a/tests/js/organization-admin-smoke.mjs +++ b/tests/js/organization-admin-smoke.mjs @@ -12,11 +12,11 @@ for (const contract of ['class OrganizationEditor', 'Direkte Hierarchie', 'Fachl if (editorSource.includes('Fachrollen und Nextcloud-Gruppen') || editorSource.includes('columnHeader(') || editorSource.includes('roleRow(')) throw new Error('Die breite Rollen-Einstellungstabelle ist weiterhin vorhanden.'); for (const contract of ['class HierarchyBoard', 'onEditRole', 'onZoomChange', 'data-action="edit-role"', 'data-action="zoom-in"', 'data-action="zoom-out"', 'data-action="zoom-reset"', 'data-organigram-viewport', 'tabindex="0"', 'startPan(', 'movePan(', 'finishPan(', 'normalizeZoom(', 'setZoom(', 'draggable="true"', 'data-position-node', 'data-diagram-level-list', 'data-action="move-node-left"', 'data-action="move-node-right"', 'getDiagramOrder()', 'insertionTarget(', 'applyDiagramOrderMove(', 'addEdge(manager, target)', 'Diese Verbindung würde einen Hierarchiezyklus erzeugen.', 'levels(roleKeys)', 'diagramNodes(roleKeys)', 'diagramEdges(nodes)', 'positionText(roleKey, areaKey)', 'data-hierarchy-links', 'drawConnections()', "createElementNS('http://www.w3.org/2000/svg', 'path')", 'orgs-connection-list', 'orgs-card-person']) if (!hierarchySource.includes(contract)) throw new Error(`Organigramm-Vertrag fehlt: ${contract}`); if (hierarchySource.includes('Keine direkt unterstellte Rolle') || hierarchySource.includes('class="orgs-edges"')) throw new Error('Unterstellte Rollen stehen weiterhin textlastig innerhalb der Diagrammknoten.'); -for (const contract of ['/api/ad-suite/admin/settings', '/api/ad-suite/admin/organization', '/api/ad-suite/admin/permissions', 'calendarPeerEditing', 'vacationPeerApproval', 'renderDirectoryStatus', 'orgs-directory-groups', 'data.directory?.positions || []', 'setOrganigramZoom', 'data.dashboardLayout?.organigram?.zoom']) { +for (const contract of ['/api/ad-suite/admin/settings', '/api/ad-suite/admin/calendar-context', '/api/ad-suite/admin/organization', '/api/ad-suite/admin/permissions', 'calendarContext', 'renderCalendarContext', 'collectCalendarContext', 'calendarPeerEditing', 'vacationPeerApproval', 'renderDirectoryStatus', 'orgs-directory-groups', 'data.directory?.positions || []', 'setOrganigramZoom', 'data.dashboardLayout?.organigram?.zoom']) { if (!adminSource.includes(contract)) throw new Error(`Admin-Frontendvertrag fehlt: ${contract}`); } const template = readFileSync(new URL('../../templates/organization-admin.php', import.meta.url), 'utf8'); -for (const contract of ['orgs-directory-status', 'orgs-directory-groups', 'Verzeichnis- und LDAP-Kompatibilität']) if (!template.includes(contract)) throw new Error(`Verzeichnisdiagnose-Markup fehlt: ${contract}`); +for (const contract of ['orgs-directory-status', 'orgs-directory-groups', 'Verzeichnis- und LDAP-Kompatibilität', 'orgs-calendar-context-form', 'orgs-calendar-country', 'orgs-calendar-subdivision', 'orgs-calendar-timezone', 'Kalenderregion und fachliche Zeitzone']) if (!template.includes(contract)) throw new Error(`Admin-Markup fehlt: ${contract}`); for (const contract of ['width: 100%', 'max-width: none', 'overflow-x: auto', '.orgs-organigram', '.orgs-organigram-toolbar', '.orgs-organigram.is-panning', '.orgs-diagram-workspace.is-editing', '.orgs-role-editor', '.orgs-compact-list', '.orgs-setting-card', '.orgs-export', '.orgs-card.is-drag-over', '.orgs-card.is-position-before', '.orgs-card.is-position-after', '.orgs-position-handle', '.orgs-sort-handle', '.orgs-diagram-links', '.orgs-card-person', 'background-image:']) { if (!css.includes(contract)) throw new Error(`Admin-Layoutvertrag fehlt: ${contract}`); } diff --git a/tests/js/organization-dashboard-smoke.mjs b/tests/js/organization-dashboard-smoke.mjs index 7bceef8..31676df 100644 --- a/tests/js/organization-dashboard-smoke.mjs +++ b/tests/js/organization-dashboard-smoke.mjs @@ -10,7 +10,7 @@ const css = readFileSync(new URL('../../css/organization-admin.css', import.meta for (const contract of ['class OrganizationDashboard', 'data-dashboard-scope', 'data-dashboard-widget', 'data-dashboard-toggle', 'data-dashboard-handle', 'data-dashboard-move', 'aria-expanded', 'collectLayout(', 'applyLayout(', 'moveWidget(']) { if (!source.includes(contract)) throw new Error(`Persönlicher Dashboardvertrag fehlt: ${contract}`); } -for (const widget of ['directory', 'organization', 'permissions', 'calendar-permissions', 'vacation-permissions']) if (!template.includes(`data-widget-id="${widget}"`)) throw new Error(`Statischer Dashboardblock fehlt: ${widget}`); +for (const widget of ['directory', 'calendar-context', 'organization', 'permissions', 'calendar-permissions', 'vacation-permissions']) if (!template.includes(`data-widget-id="${widget}"`)) throw new Error(`Statischer Dashboardblock fehlt: ${widget}`); for (const widget of ['general', 'hierarchy', 'role-order', 'areas', 'vacation-views']) if (!editor.includes(`dashboardWidget('${widget}'`)) throw new Error(`Organisations-Dashboardblock fehlt: ${widget}`); if (!template.includes('orgs-dashboard-collection') || !template.includes('data-widget-id="organization"')) throw new Error('AD-Organisation ist nicht als ungerahmte Sammlung eigenständiger Cards markiert.'); for (const contract of ["components/organization-dashboard", '/api/ad-suite/admin/layout', 'dashboardLayout', 'saveDashboardLayout']) if (!template.includes(contract) && !admin.includes(contract)) throw new Error(`Dashboardanbindung fehlt: ${contract}`); From a313bed202a9c5002be2a3a7261132851ec06979 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 26 Jul 2026 21:30:08 +0200 Subject: [PATCH 2/4] docs: align local TDD risk checks --- .agents/skills/test-driven-change/SKILL.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.agents/skills/test-driven-change/SKILL.md b/.agents/skills/test-driven-change/SKILL.md index 6c148dd..fff4b71 100644 --- a/.agents/skills/test-driven-change/SKILL.md +++ b/.agents/skills/test-driven-change/SKILL.md @@ -55,13 +55,17 @@ For permission changes, verify at least: - unauthorized access is rejected; - authorized access succeeds; -- a foreign or manipulated object ID causes no data change. +- a foreign or manipulated object ID grants no access and causes no data + change; +- a rejected request changes no data; +- UI visibility is never used as a substitute for server-side access control. For database or migration changes, verify at least: - fresh installation on an empty schema; - upgrade from at least the immediately relevant prior version; - preservation or correct migration of existing data; +- required constraints and indexes exist and preserve integrity; - no incomplete state after failure. For shared libraries or contracts, verify: From 38a48bd7e6fc3be44030006afc768e1b0efe7bba Mon Sep 17 00:00:00 2001 From: filzmann Date: Mon, 27 Jul 2026 13:42:23 +0200 Subject: [PATCH 3/4] test: harden shared PHP coverage execution --- tests/Support/CoverageThresholdSmokeTest.php | 55 ++++++++++++++++++++ tests/Support/PhpTestRunner.php | 15 +++++- tests/Support/PhpTestRunnerSmokeTest.php | 23 ++++++++ tests/coverage/merge-clover.php | 19 ++++++- 4 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 tests/Support/CoverageThresholdSmokeTest.php diff --git a/tests/Support/CoverageThresholdSmokeTest.php b/tests/Support/CoverageThresholdSmokeTest.php new file mode 100644 index 0000000..ed89923 --- /dev/null +++ b/tests/Support/CoverageThresholdSmokeTest.php @@ -0,0 +1,55 @@ + + + + + + + + + +XML; +file_put_contents($temporaryBase . '/fixture.xml', $report); + +$tool = dirname(__DIR__) . '/coverage/merge-clover.php'; +$run = static function (string $minimum) use ($tool, $temporaryBase): array { + $command = implode(' ', array_map('escapeshellarg', [ + PHP_BINARY, + $tool, + 'fixture', + $temporaryBase, + $minimum, + ])); + exec($command . ' 2>&1', $output, $exitCode); + return [$exitCode, implode("\n", $output)]; +}; + +try { + [$passingStatus, $passingOutput] = $run('50.00'); + if ($passingStatus !== 0 || !str_contains($passingOutput, "fixture\t2\t1\t50.00")) { + throw new RuntimeException('Exakt erreichte PHP-Coverage-Schwelle wird nicht akzeptiert.'); + } + + [$failingStatus, $failingOutput] = $run('50.01'); + if ($failingStatus === 0 || !str_contains($failingOutput, '50.00 % liegt unter 50.01 %')) { + throw new RuntimeException('PHP-Coverage-Rückgang wird nicht mit Ist- und Sollwert abgelehnt.'); + } +} finally { + unlink($temporaryBase . '/fixture.xml'); + rmdir($temporaryBase); +} + +echo "LocalBase PHP coverage threshold smoke test passed\n"; diff --git a/tests/Support/PhpTestRunner.php b/tests/Support/PhpTestRunner.php index 73ed305..3a5ed70 100644 --- a/tests/Support/PhpTestRunner.php +++ b/tests/Support/PhpTestRunner.php @@ -88,7 +88,18 @@ public static function withOptionalCoverage(string $root, array $command): array } $script = (string)($command[1] ?? 'test'); - $report = rtrim($outputDirectory, '/') . '/' . hash('sha256', $root . '/' . $script) . '.xml'; + $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'; + $wrapperCode = " \n"); +if ($argc !== 3 && $argc !== 4) { + fwrite(STDERR, "Aufruf: php merge-clover.php [Mindest-Coverage]\n"); exit(2); } $appId = $argv[1]; $directory = $argv[2]; +$minimum = null; +if ($argc === 4) { + if (!is_numeric($argv[3]) || (float)$argv[3] < 0.0 || (float)$argv[3] > 100.0) { + fwrite(STDERR, "Ungültige Mindest-Coverage: {$argv[3]}\n"); + exit(2); + } + $minimum = (float)$argv[3]; +} $reports = glob(rtrim($directory, '/') . '/*.xml') ?: []; if ($reports === []) { throw new RuntimeException("Keine Clover-Berichte für {$appId} gefunden."); @@ -39,3 +47,10 @@ } $percent = $executable === 0 ? 0.0 : ($covered / $executable) * 100; printf("%s\t%d\t%d\t%.2f\n", $appId, $executable, $covered, $percent); +if ($minimum !== null && round($percent, 2) < $minimum) { + fwrite( + STDERR, + sprintf("%s: %.2f %% liegt unter %.2f %%.\n", $appId, $percent, $minimum), + ); + exit(1); +} From 064316a254cfb258bbd176ffcea8329a80eeee63 Mon Sep 17 00:00:00 2001 From: filzmann Date: Mon, 27 Jul 2026 13:42:28 +0200 Subject: [PATCH 4/4] ci: provide shared JavaScript coverage tooling --- .github/workflows/tests.yml | 67 +- tests/coverage/package-lock.json | 739 ++++++++++++++++++++++ tests/coverage/package.json | 10 + tests/js/organization-admin-smoke.mjs | 5 +- tests/js/organization-dashboard-smoke.mjs | 3 +- tests/js/organization-exporter-smoke.mjs | 5 +- 6 files changed, 821 insertions(+), 8 deletions(-) create mode 100644 tests/coverage/package-lock.json create mode 100644 tests/coverage/package.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 742342b..be0b5d0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,11 +30,24 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - coverage: none + coverage: ${{ matrix.php-version == '8.3' && 'xdebug' || 'none' }} tools: none - name: PHP-Tests + if: matrix.php-version == '8.5' working-directory: localbase run: php tests/run.php + - name: PHP-Coverage-Tooling installieren + if: matrix.php-version == '8.3' + run: composer install --working-dir=localbase/tests/coverage --no-interaction --no-progress --prefer-dist + - name: PHP-Coverage + if: matrix.php-version == '8.3' + working-directory: localbase + run: | + mkdir -p "$RUNNER_TEMP/php-coverage" + PHP_COVERAGE_COMMAND="$GITHUB_WORKSPACE/localbase/tests/coverage/vendor/bin/phpcov" \ + PHP_COVERAGE_OUTPUT_DIR="$RUNNER_TEMP/php-coverage" \ + php tests/run.php + php "$GITHUB_WORKSPACE/localbase/tests/coverage/merge-clover.php" localbase "$RUNNER_TEMP/php-coverage" 95.71 javascript: name: JavaScript @@ -50,9 +63,17 @@ jobs: with: node-version: 24 package-manager-cache: false - - name: JavaScript-Tests + - name: JavaScript-Coverage-Tooling installieren + run: npm ci --prefix localbase/tests/coverage --ignore-scripts + - name: JavaScript-Tests mit Coverage working-directory: localbase - run: node tests/run-js.mjs + run: | + tests/coverage/node_modules/.bin/c8 \ + --all \ + '--include=js/**/*.js' \ + --check-coverage \ + --lines=61.14 \ + node tests/run-js.mjs consumer-contracts: name: Verbraucher-Verträge @@ -89,6 +110,26 @@ jobs: with: repository: Filzmann/nextcloud-adroom path: adroom + - name: BRTop auschecken + uses: actions/checkout@v7 + with: + repository: Filzmann/nextcloud-brtop + path: brtop + - name: BRStunden auschecken + uses: actions/checkout@v7 + with: + repository: Filzmann/nextcloud-brstunden + path: brstunden + - name: Berechtigungsmatrix auschecken + uses: actions/checkout@v7 + with: + repository: Filzmann/nextcloud-br-permission-matrix + path: br_permission_matrix + - name: AD Recruitment auschecken + uses: actions/checkout@v7 + with: + repository: Filzmann/nextcloud-recruitment + path: adrecruitment - name: PHP einrichten uses: shivammathur/setup-php@v2 with: @@ -125,3 +166,23 @@ jobs: run: | php tests/run.php node tests/run-js.mjs + - name: BRTop-Vertrag + working-directory: brtop + run: | + php tests/run.php + node tests/run-js.mjs + - name: BRStunden-Vertrag + working-directory: brstunden + run: | + php tests/run.php + node tests/run-js.mjs + - name: Berechtigungsmatrix-Vertrag + working-directory: br_permission_matrix + run: | + php tests/run.php + node tests/run-js.mjs + - name: AD-Recruitment-Vertrag + working-directory: adrecruitment + run: | + php tests/run.php + node tests/run-js.mjs diff --git a/tests/coverage/package-lock.json b/tests/coverage/package-lock.json new file mode 100644 index 0000000..b032e12 --- /dev/null +++ b/tests/coverage/package-lock.json @@ -0,0 +1,739 @@ +{ + "name": "br-nextcloud-apps-js-coverage", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "br-nextcloud-apps-js-coverage", + "devDependencies": { + "c8": "12.0.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/tests/coverage/package.json b/tests/coverage/package.json new file mode 100644 index 0000000..ebd1295 --- /dev/null +++ b/tests/coverage/package.json @@ -0,0 +1,10 @@ +{ + "name": "br-nextcloud-apps-js-coverage", + "private": true, + "engines": { + "node": ">=24" + }, + "devDependencies": { + "c8": "12.0.0" + } +} diff --git a/tests/js/organization-admin-smoke.mjs b/tests/js/organization-admin-smoke.mjs index 9468942..75c87c9 100644 --- a/tests/js/organization-admin-smoke.mjs +++ b/tests/js/organization-admin-smoke.mjs @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { runInNewContext } from 'node:vm'; const editorSource = readFileSync(new URL('../../js/components/organization-editor.js', import.meta.url), 'utf8'); @@ -26,8 +27,8 @@ if (!/\.orgs-card\s*\{[^}]*width:\s*fit-content[^}]*min-width:\s*11rem[^}]*max-w if (!/\.orgs-dashboard-widget\[data-widget-id="hierarchy"\]\s*\{[^}]*grid-column:\s*1\s*\/\s*-1/.test(css)) throw new Error('Der Organigramm-Block nutzt nicht verbindlich die volle verfügbare Breite.'); const context = { window: { LocalBase: { ui: { esc: value => String(value ?? '') } } }, JSON, Set, Math, Object, Element: class {} }; -runInNewContext(hierarchySource, context); -runInNewContext(editorSource, context); +runInNewContext(hierarchySource, context, { filename: fileURLToPath(new URL('../../js/components/hierarchy-board.js', import.meta.url)) }); +runInNewContext(editorSource, context, { filename: fileURLToPath(new URL('../../js/components/organization-editor.js', import.meta.url)) }); const board = Object.create(context.window.LocalBase.components.HierarchyBoard.prototype); if (board.normalizeZoom(20) !== 50 || board.normalizeZoom(137) !== 140 || board.normalizeZoom(190) !== 150) throw new Error('Persönlicher Zoom wird nicht auf sichere Grenzen und Schritte normalisiert.'); let emittedZoom = null; diff --git a/tests/js/organization-dashboard-smoke.mjs b/tests/js/organization-dashboard-smoke.mjs index 31676df..d3914aa 100644 --- a/tests/js/organization-dashboard-smoke.mjs +++ b/tests/js/organization-dashboard-smoke.mjs @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import vm from 'node:vm'; const source = readFileSync(new URL('../../js/components/organization-dashboard.js', import.meta.url), 'utf8'); @@ -20,7 +21,7 @@ if (!/\.orgs-dashboard-collection\s*>\s*\[data-dashboard-content\]\s*\{[^}]*padd const context = { window: { LocalBase: { components: {} } }, Element: class {} }; vm.createContext(context); -vm.runInContext(source, context); +vm.runInContext(source, context, { filename: fileURLToPath(new URL('../../js/components/organization-dashboard.js', import.meta.url)) }); const Dashboard = context.window.LocalBase.components.OrganizationDashboard; const dashboard = Object.create(Dashboard.prototype); let changed = 0; diff --git a/tests/js/organization-exporter-smoke.mjs b/tests/js/organization-exporter-smoke.mjs index 6a4b63f..7542ce4 100644 --- a/tests/js/organization-exporter-smoke.mjs +++ b/tests/js/organization-exporter-smoke.mjs @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { runInNewContext } from 'node:vm'; const boardSource = readFileSync(new URL('../../js/components/hierarchy-board.js', import.meta.url), 'utf8'); @@ -17,8 +18,8 @@ const context = { window: { LocalBase: { ui: { esc: value => String(value ?? '') } } }, JSON, Set, Map, Math, Object, Element: class {}, Blob: class {}, URL: {}, Image: class {}, XMLSerializer: class {}, }; -runInNewContext(boardSource, context); -runInNewContext(exporterSource, context); +runInNewContext(boardSource, context, { filename: fileURLToPath(new URL('../../js/components/hierarchy-board.js', import.meta.url)) }); +runInNewContext(exporterSource, context, { filename: fileURLToPath(new URL('../../js/components/organization-exporter.js', import.meta.url)) }); const board = Object.create(context.window.LocalBase.components.HierarchyBoard.prototype); board.roles = {