From 8e0cfcd7d44d6447d72348d9acc8d2322fc76dad Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 26 Jul 2026 20:12:25 +0200 Subject: [PATCH 1/4] feat: use shared calendar context for room holidays --- .agents/skills/test-driven-change/SKILL.md | 109 ++++++++++++++++++ .agents/skills/work-in-nextcloud-app/SKILL.md | 12 +- AGENTS.md | 4 +- README.md | 2 + ROADMAP.md | 2 +- appinfo/info.xml | 2 +- lib/Service/BookingService.php | 4 +- lib/Service/HolidayService.php | 59 +++++++--- lib/Service/RoomDemoPackService.php | 11 +- tests/Service/BookingMonthTest.php | 10 +- tests/Service/BookingServiceTest.php | 10 +- tests/Service/HolidayServiceTest.php | 27 +++-- tests/Service/RoomDemoCalendarContextTest.php | 11 ++ 13 files changed, 225 insertions(+), 38 deletions(-) create mode 100644 .agents/skills/test-driven-change/SKILL.md create mode 100644 tests/Service/RoomDemoCalendarContextTest.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 27e31bb..b85bc1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,8 @@ Die priorisierte Produktplanung und offene Entscheidungen stehen in `ROADMAP.md` - Alle angemeldeten Nutzer*innen duerfen Raeume und Buchungen lesen sowie eigene Buchungen anlegen, bearbeiten, in andere Raeume verschieben und loeschen. - Nextcloud-Admins duerfen alle Buchungen und die Raumliste verwalten. - Raumloeschungen loeschen die zugehoerigen Buchungen. Die UI muss diese Auswirkung vor der Aktion deutlich bestaetigen. -- Samstage, Sonntage und gesetzliche Feiertage in Berlin werden in der Monatsansicht textlich und optisch gekennzeichnet. +- Samstage, Sonntage und die gesetzlichen Feiertage der zentral in LocalBase konfigurierten Organisationsregion werden in der Monatsansicht textlich und optisch gekennzeichnet. Ohne abweichende Administration gilt Berlin. +- Buchungszeiten und Monatsgrenzen verwenden die zentral konfigurierte fachliche Organisationszeitzone. Persönliche Nextcloud-Zeitzonen verändern nur die individuelle Anzeige, nicht den fachlichen Buchungskontext. - Der WordPress-Raumplaner ist nur fachliche Referenz. WordPress-IDs, Capabilities, Nonces, Shortcodes und Tabellen werden nicht uebernommen. - WordPress-Bestandsdaten werden nicht importiert. Der app-eigene Adminabschnitt installiert neutrale Räume und Buchungen ausschließlich als manuell bestätigten synthetischen Demo-Pack. - Beispielbuchungen gehören einem explizit registrierten lokalen Demokonto; ein vorhandenes fremdes oder LDAP-verwaltetes Konto wird niemals dafür wiederverwendet. @@ -32,6 +33,7 @@ Die priorisierte Produktplanung und offene Entscheidungen stehen in `ROADMAP.md` ## Architektur und Sicherheit - Controller bleiben duenn. Validierung und Kollisionspruefung liegen im `BookingService`, Rechte im `RoomAccessService`, Datenzugriff in Repositories. +- `HolidayService` ist nur ein app-spezifischer Projektionsadapter auf den gemeinsamen, zwischengespeicherten LocalBase-Feiertagskalender; AD Raumplaner pflegt keine eigene Feiertagsquelle oder Regionstabelle. - Deny by default: Jede schreibende API prueft die angemeldete Person und die Zielbuchung serverseitig. - Der Browser uebermittelt bei eigenen Buchungen keine vertrauenswuerdige Besitzer-UID; der Server setzt die UID aus der Session. - GET-Routen sind CSRF-frei, schreibende Routen behalten den Nextcloud-CSRF-Schutz. diff --git a/README.md b/README.md index 2a83c42..92d1cea 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ AD Raumplaner funktioniert einzeln; Buchungen bleiben ohne Kalender oder Assiste Räume werden nach der Aktivierung im eigenen Nextcloud-Adminabschnitt `AD Raumplaner` eingerichtet. `adroom:demo:seed` ist ausschließlich für synthetische Testdaten bestimmt. +Feiertage, Buchungszeiten und Monatsgrenzen richten sich nach dem gemeinsamen Kalenderkontext der AD-Suite. Land, Region und fachliche Zeitzone werden zentral durch die Administration gepflegt; ohne Änderung gilt Deutschland/Berlin. + ## Roadmap Geplante Erweiterungen und offene Produktentscheidungen stehen in der [Roadmap](ROADMAP.md). diff --git a/ROADMAP.md b/ROADMAP.md index 332bc59..64791d6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ Diese Datei bündelt geplante Erweiterungen und offene Produktentscheidungen. Ve ## Aktueller Fokus - Monatsansicht, Kollisionsschutz, eigene Buchungsrechte und administrative Raumverwaltung auf einem realitätsnahen Staging fachlich abnehmen. -- Löschbestätigung, Zeitraster, Wochenenden und Berliner Feiertage sichtbar und barrierefrei prüfen. +- Löschbestätigung, Zeitraster, Wochenenden und die Feiertage der administrativ gewählten Organisationsregion sichtbar und barrierefrei prüfen. ## Geplante Erweiterungen diff --git a/appinfo/info.xml b/appinfo/info.xml index f8176e2..ce645f1 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ AD Raumplaner Monatliche Raumbelegung und kollisionsfreie Raumbuchungen. Verwaltet Räume und eigene beziehungsweise administrative Buchungen in einer kompakten Monatsmatrix. - 0.9.0-rc.3 + 0.10.0-rc.1 agpl Simon https://github.com/Filzmann/ad-suite diff --git a/lib/Service/BookingService.php b/lib/Service/BookingService.php index 4e41356..3110d0c 100644 --- a/lib/Service/BookingService.php +++ b/lib/Service/BookingService.php @@ -10,6 +10,7 @@ use OCA\AdRoom\Model\Booking; use OCA\AdRoom\Model\Room; use OCA\AdRoom\Repository\BookingRepository; +use OCA\LocalBase\Calendar\CalendarContextSettingsService; use OCP\IUserManager; /** @@ -25,8 +26,9 @@ public function __construct( private RoomService $rooms, private IUserManager $users, private HolidayService $holidays, + CalendarContextSettingsService $contexts, ) { - $this->localTimezone = new DateTimeZone('Europe/Berlin'); + $this->localTimezone = $contexts->context()->timezone(); $this->utc = new DateTimeZone('UTC'); } diff --git a/lib/Service/HolidayService.php b/lib/Service/HolidayService.php index e6a0331..6078c74 100644 --- a/lib/Service/HolidayService.php +++ b/lib/Service/HolidayService.php @@ -5,27 +5,50 @@ namespace OCA\AdRoom\Service; use DateTimeImmutable; -use DateTimeZone; +use OCA\LocalBase\Calendar\HolidayCalendarService as SharedHolidayCalendarService; -/** Zweck: Liefert die fuer Berlin geltenden Feiertage ohne externen Laufzeitdienst. */ +/** Zweck: Projiziert die gemeinsamen regionalen Feiertage auf den angefragten Monat. */ final class HolidayService { + public function __construct(private SharedHolidayCalendarService $calendars) {} + /** @return array */ public function forMonth(int $year, int $month): array { - $tz = new DateTimeZone('Europe/Berlin'); - $easter = (new DateTimeImmutable(sprintf('%04d-03-21',$year),$tz))->modify('+' . easter_days($year) . ' days'); - $dates = [ - sprintf('%04d-01-01',$year)=>'Neujahr', - sprintf('%04d-03-08',$year)=>'Internationaler Frauentag', - $easter->modify('-2 days')->format('Y-m-d')=>'Karfreitag', - $easter->modify('+1 day')->format('Y-m-d')=>'Ostermontag', - sprintf('%04d-05-01',$year)=>'Tag der Arbeit', - $easter->modify('+39 days')->format('Y-m-d')=>'Christi Himmelfahrt', - $easter->modify('+50 days')->format('Y-m-d')=>'Pfingstmontag', - sprintf('%04d-10-03',$year)=>'Tag der Deutschen Einheit', - sprintf('%04d-12-25',$year)=>'1. Weihnachtstag', - sprintf('%04d-12-26',$year)=>'2. Weihnachtstag', - ]; - return array_filter($dates,static fn(string $name,string $date): bool => (int)substr($date,5,2)===$month,ARRAY_FILTER_USE_BOTH); + if ($month < 1 || $month > 12) { + throw new \InvalidArgumentException('Monat ist ungültig.'); + } + + $monthPrefix = sprintf('%04d-%02d-', $year, $month); + $dates = []; + foreach ($this->calendars->forYear($year)->toArray()['publicHolidays'] ?? [] as $period) { + if (!is_array($period)) { + continue; + } + + $name = trim((string)($period['name'] ?? '')); + $start = $this->parseDate($period['startDate'] ?? null); + $end = $this->parseDate($period['endDate'] ?? null); + if ($name === '' || $start === null || $end === null || $end < $start) { + continue; + } + + for ($date = $start; $date <= $end; $date = $date->modify('+1 day')) { + $key = $date->format('Y-m-d'); + if (str_starts_with($key, $monthPrefix)) { + $dates[$key] = $name; + } + } + } + + ksort($dates); + return $dates; } -} + private function parseDate(mixed $value): ?DateTimeImmutable { + if (!is_string($value)) { + return null; + } + + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + return $date !== false && $date->format('Y-m-d') === $value ? $date : null; + } +} diff --git a/lib/Service/RoomDemoPackService.php b/lib/Service/RoomDemoPackService.php index 1b92b29..8c2fd92 100644 --- a/lib/Service/RoomDemoPackService.php +++ b/lib/Service/RoomDemoPackService.php @@ -5,13 +5,18 @@ namespace OCA\AdRoom\Service; use DateTimeImmutable; -use DateTimeZone; use OCA\AdRoom\Exception\BookingConflictException; +use OCA\LocalBase\Calendar\CalendarContextSettingsService; use OCA\LocalBase\Service\DemoAccountProvisioningService; /** Zweck: Installiert neutrale Räume und Beispielbuchungen unter einem registrierten lokalen Demokonto. */ final class RoomDemoPackService { - public function __construct(private DemoAccountProvisioningService $accounts, private RoomService $rooms, private BookingService $bookings) {} + public function __construct( + private DemoAccountProvisioningService $accounts, + private RoomService $rooms, + private BookingService $bookings, + private CalendarContextSettingsService $contexts, + ) {} /** @return array{accounts:array,rooms:int,createdBookings:int,skippedBookings:int} */ public function install(): array { @@ -29,7 +34,7 @@ public function install(): array { if (!isset($existing[$definition['name']])) $existing[$definition['name']] = $this->rooms->save(null, $definition['name'], $definition['description'], $definition['sortOrder']); } - $day = new DateTimeImmutable('next monday', new DateTimeZone('Europe/Berlin')); + $day = new DateTimeImmutable('next monday', $this->contexts->context()->timezone()); $samples = [ ['Besprechungsraum Nord', '10:00', '11:00', 'AT', 'ASN Team A'], ['Besprechungsraum Süd', '12:00', '13:30', 'Sitzung', 'Büroteam Süd'], diff --git a/tests/Service/BookingMonthTest.php b/tests/Service/BookingMonthTest.php index 0ea257e..a0b3271 100644 --- a/tests/Service/BookingMonthTest.php +++ b/tests/Service/BookingMonthTest.php @@ -6,6 +6,12 @@ interface IUser { public function getDisplayName(): string; } interface IUserManager { public function get(string $uid): ?IUser; } } +namespace OCA\LocalBase\Calendar { + class Context { public function timezone(): \DateTimeZone { return new \DateTimeZone('Europe/London'); } } + class CalendarContextSettingsService { public function context(): Context { return new Context(); } } + class HolidayCalendar { public function toArray(): array { return ['publicHolidays' => [['name' => 'Feiertag', 'startDate' => '2026-05-01', 'endDate' => '2026-05-01']]]; } } + class HolidayCalendarService { public function forYear(int $year): HolidayCalendar { return new HolidayCalendar(); } } +} namespace OCA\AdRoom\Repository { use DateTimeImmutable; @@ -57,12 +63,12 @@ public function canManageRooms(): bool { return true; } $repository->bookings = [Booking::get(['id' => 5, 'roomId' => 2, 'userUid' => 'anna', 'purpose' => 'LG', 'title' => 'Leitung', 'startsAt' => '2026-07-13T06:00:00+00:00', 'endsAt' => '2026-07-13T07:00:00+00:00'])]; $user = new class implements IUser { public function getDisplayName(): string { return 'Anna Beispiel'; } }; $users = new class($user) implements IUserManager { public function __construct(private IUser $user) {} public function get(string $uid): ?IUser { return $uid === 'anna' ? $this->user : null; } }; - $service = new BookingService($repository, new RoomService(), $users, new HolidayService()); + $service = new BookingService($repository, new RoomService(), $users, new HolidayService(new \OCA\LocalBase\Calendar\HolidayCalendarService()), new \OCA\LocalBase\Calendar\CalendarContextSettingsService()); $month = $service->month('2026-05', new RoomAccessService()); if ($month['month'] !== '2026-05' || $month['bookings'][0]['userName'] !== 'Anna Beispiel' || !$month['bookings'][0]['canManage'] || !$month['capabilities']['canManageRooms']) { throw new RuntimeException('Monatsansicht projiziert Buchungen oder Rechte nicht korrekt.'); } - if ($repository->lastRange[0]->format(DATE_ATOM) !== '2026-04-30T22:00:00+00:00' || $month['holidays'] === []) throw new RuntimeException('Monatsgrenzen oder Feiertage fehlen.'); + if ($repository->lastRange[0]->format(DATE_ATOM) !== '2026-04-30T23:00:00+00:00' || $month['holidays'] === []) throw new RuntimeException('Administrative Monatsgrenzen oder gemeinsame Feiertage fehlen.'); foreach (['Juli 2026', '2026-00', '2026-13'] as $invalid) { try { $service->month($invalid, new RoomAccessService()); throw new RuntimeException('Ungültiger Monat wurde akzeptiert.'); } catch (InvalidArgumentException) {} } diff --git a/tests/Service/BookingServiceTest.php b/tests/Service/BookingServiceTest.php index a3ec61c..fee28d8 100644 --- a/tests/Service/BookingServiceTest.php +++ b/tests/Service/BookingServiceTest.php @@ -3,6 +3,12 @@ declare(strict_types=1); namespace OCP { interface IUserManager { public function get(string $uid); } } +namespace OCA\LocalBase\Calendar { + class Context { public function timezone(): \DateTimeZone { return new \DateTimeZone('America/New_York'); } } + class CalendarContextSettingsService { public function context(): Context { return new Context(); } } + class HolidayCalendar { public function toArray(): array { return ['publicHolidays' => []]; } } + class HolidayCalendarService { public function forYear(int $year): HolidayCalendar { return new HolidayCalendar(); } } +} namespace OCA\AdRoom\Repository { class BookingRepository { public bool $overlap=false; public ?\OCA\AdRoom\Model\Booking $saved=null; @@ -23,9 +29,9 @@ class RoomService { public function get(int $id): ?object { return $id===1 ? (ob require __DIR__.'/../../lib/Service/BookingService.php'; $repo=new OCA\AdRoom\Repository\BookingRepository(); $users=new class implements OCP\IUserManager { public function get(string $uid){ return null; } }; - $service=new OCA\AdRoom\Service\BookingService($repo,new OCA\AdRoom\Service\RoomService(),$users,new OCA\AdRoom\Service\HolidayService()); + $service=new OCA\AdRoom\Service\BookingService($repo,new OCA\AdRoom\Service\RoomService(),$users,new OCA\AdRoom\Service\HolidayService(new OCA\LocalBase\Calendar\HolidayCalendarService()),new OCA\LocalBase\Calendar\CalendarContextSettingsService()); if ($service->create(1,'2026-07-13T08:00','2026-07-13T09:00','Sitzung','Büroteam','admin')!==7) throw new RuntimeException('Gültige Buchung wurde nicht gespeichert.'); - if ($repo->saved?->startsAt()->format('H:i')!=='06:00') throw new RuntimeException('Berliner Sommerzeit wurde nicht nach UTC normalisiert.'); + if ($repo->saved?->startsAt()->format('H:i')!=='12:00') throw new RuntimeException('Administrative Fachzeitzone wurde nicht nach UTC normalisiert.'); if ($repo->saved?->title()!=='Büroteam') throw new RuntimeException('Buchungstitel wurde nicht gespeichert.'); $repo->overlap=true; try { $service->create(1,'2026-07-13T08:00','2026-07-13T09:00','Sitzung','Büroteam','admin'); throw new RuntimeException('Überschneidung wurde nicht blockiert.'); } catch (OCA\AdRoom\Exception\BookingConflictException) {} diff --git a/tests/Service/HolidayServiceTest.php b/tests/Service/HolidayServiceTest.php index 2be58dc..3b329b4 100644 --- a/tests/Service/HolidayServiceTest.php +++ b/tests/Service/HolidayServiceTest.php @@ -2,12 +2,25 @@ declare(strict_types=1); -require __DIR__.'/../../lib/Service/HolidayService.php'; +namespace OCA\LocalBase\Calendar { + final class HolidayCalendar { public function toArray(): array { return ['publicHolidays' => [ + ['name' => 'Regionaler Feiertag', 'startDate' => '2026-03-08', 'endDate' => '2026-03-08'], + ['name' => 'Zweitägiger Feiertag', 'startDate' => '2026-04-30', 'endDate' => '2026-05-01'], + ]]; } } + final class HolidayCalendarService { + public array $calls = []; + public function forYear(int $year): HolidayCalendar { $this->calls[] = $year; return new HolidayCalendar(); } + } +} -$service=new OCA\AdRoom\Service\HolidayService(); -$march=$service->forMonth(2026,3); -if (($march['2026-03-08']??'')!=='Internationaler Frauentag') throw new RuntimeException('Berliner Feiertag fehlt.'); -$april=$service->forMonth(2026,4); -if (($april['2026-04-03']??'')!=='Karfreitag' || ($april['2026-04-06']??'')!=='Ostermontag') throw new RuntimeException('Bewegliche Feiertage sind fehlerhaft.'); -echo "AD Raumplaner holiday tests passed\n"; +namespace { + require __DIR__ . '/../../lib/Service/HolidayService.php'; + $shared = new OCA\LocalBase\Calendar\HolidayCalendarService(); + $service = new OCA\AdRoom\Service\HolidayService($shared); + if (($service->forMonth(2026, 3)['2026-03-08'] ?? '') !== 'Regionaler Feiertag') throw new RuntimeException('Gemeinsamer regionaler Feiertag fehlt.'); + $may = $service->forMonth(2026, 5); + if (($may['2026-05-01'] ?? '') !== 'Zweitägiger Feiertag' || isset($may['2026-04-30'])) throw new RuntimeException('Mehrtagiger Feiertag wird nicht auf den angefragten Monat begrenzt.'); + if ($shared->calls !== [2026, 2026]) throw new RuntimeException('AD Raumplaner liest nicht den gemeinsamen Jahresvertrag.'); + echo "AD Raumplaner holiday tests passed\n"; +} diff --git a/tests/Service/RoomDemoCalendarContextTest.php b/tests/Service/RoomDemoCalendarContextTest.php new file mode 100644 index 0000000..5b3eb60 --- /dev/null +++ b/tests/Service/RoomDemoCalendarContextTest.php @@ -0,0 +1,11 @@ +contexts->context()->timezone()'] as $contract) { + if (!str_contains($source, $contract)) throw new RuntimeException("Demopack verwendet nicht den gemeinsamen Kalenderkontext: {$contract}"); +} +if (str_contains($source, "new DateTimeZone('Europe/Berlin')")) throw new RuntimeException('Demopack enthält weiterhin eine feste Berliner Zeitzone.'); +echo "RoomDemoCalendarContextTest: OK\n"; From bede4dcb3ce49138e05d0fffab002d1adf836346 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 2a94fcb782bf1175bfec4d8e30bb1bcfc8a6fd2e Mon Sep 17 00:00:00 2001 From: filzmann Date: Mon, 27 Jul 2026 13:45:43 +0200 Subject: [PATCH 3/4] test: cover controller and settings execution --- .../DemoAdminControllerExecutionTest.php | 96 +++++++++++++++++++ tests/SettingsExecutionTest.php | 61 ++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 tests/Controller/DemoAdminControllerExecutionTest.php create mode 100644 tests/SettingsExecutionTest.php diff --git a/tests/Controller/DemoAdminControllerExecutionTest.php b/tests/Controller/DemoAdminControllerExecutionTest.php new file mode 100644 index 0000000..6b68ecd --- /dev/null +++ b/tests/Controller/DemoAdminControllerExecutionTest.php @@ -0,0 +1,96 @@ +data; } + public function getStatus(): int { return $this->status; } + } +} + +namespace Psr\Log { + interface LoggerInterface { + public function error(string $message, array $context = []): void; + } +} + +namespace OCA\AdRoom\AppInfo { + final class Application { public const APP_ID = 'adroom'; } +} + +namespace OCA\AdRoom\Service { + final class RoomDemoPackService { + public bool $fail = false; + public function install(): array { + if ($this->fail) throw new \RuntimeException('Demo nicht verfügbar.'); + return ['rooms' => 3, 'createdBookings' => 2]; + } + } +} + +namespace { + require __DIR__ . '/../../lib/Controller/DemoAdminController.php'; + + use OCA\AdRoom\Controller\DemoAdminController; + use OCA\AdRoom\Service\RoomDemoPackService; + use OCP\AppFramework\Http; + use OCP\IGroupManager; + use OCP\IRequest; + use OCP\IUserSession; + + $request = new class implements IRequest {}; + $session = new class implements IUserSession { + public ?object $user = null; + public function getUser(): ?object { return $this->user; } + }; + $groups = new class implements IGroupManager { + public bool $admin = false; + public function isAdmin($uid): bool { return $this->admin; } + }; + $demoPack = new RoomDemoPackService(); + $logger = new class implements \Psr\Log\LoggerInterface { + public array $errors = []; + public function error(string $message, array $context = []): void { $this->errors[] = [$message, $context]; } + }; + $controller = new DemoAdminController($request, $session, $groups, $demoPack, $logger); + $assert = static function (bool $condition, string $message): void { + if (!$condition) throw new RuntimeException($message); + }; + + $assert($controller->install()->getStatus() === Http::STATUS_FORBIDDEN, 'Anonymous users can install demo data.'); + $session->user = new class { + public function getUID(): string { return 'anna'; } + }; + $assert($controller->install()->getStatus() === Http::STATUS_FORBIDDEN, 'Non-admin users can install demo data.'); + + $groups->admin = true; + $response = $controller->install(); + $assert($response->getStatus() === 200, 'Admins cannot install demo data.'); + $assert($response->getData()['result']['rooms'] === 3, 'The demo result is not forwarded.'); + + $demoPack->fail = true; + $response = $controller->install(); + $assert($response->getStatus() === Http::STATUS_BAD_REQUEST, 'Demo failures do not return a safe client status.'); + $assert($response->getData()['error'] === 'Demo nicht verfügbar.', 'Demo failures lose their actionable message.'); + $assert($logger->errors[0][0] === 'Raum-Demo-Pack konnte nicht installiert werden.', 'Demo failures are not logged.'); + + echo "AD Raumplaner demo admin controller tests passed\n"; +} diff --git a/tests/SettingsExecutionTest.php b/tests/SettingsExecutionTest.php new file mode 100644 index 0000000..b9ffbd3 --- /dev/null +++ b/tests/SettingsExecutionTest.php @@ -0,0 +1,61 @@ +getForm(); + $assert($form->appName === 'adroom' && $form->templateName === 'admin', 'The admin form points to the wrong template.'); + $assert($admin->getSection() === 'adroom', 'The admin form points to the wrong section.'); + $assert($admin->getPriority() === 30, 'The admin form priority changed unexpectedly.'); + + $url = new class implements IURLGenerator { + public function imagePath($app, $file): string { return "{$app}/img/{$file}"; } + }; + $section = new AdminSection($url); + $assert($section->getIcon() === 'adroom/img/app.svg', 'The admin section points to the wrong icon.'); + $assert($section->getID() === 'adroom', 'The admin section exposes the wrong id.'); + $assert($section->getName() === 'AD Raumplaner', 'The admin section exposes the wrong name.'); + $assert($section->getPriority() === 65, 'The admin section priority changed unexpectedly.'); + + echo "AD Raumplaner settings execution tests passed\n"; +} From 596a8a8b83fa3a34622cb3bcccb628af98fe93aa Mon Sep 17 00:00:00 2001 From: filzmann Date: Mon, 27 Jul 2026 13:45:48 +0200 Subject: [PATCH 4/4] ci: enforce PHP and JavaScript coverage baselines --- .github/workflows/tests.yml | 27 ++++++++++++++++++++++++--- tests/js/frontend-smoke.mjs | 7 ++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2170c07..e6ed696 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,11 +46,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: app 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: app + 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" adroom "$RUNNER_TEMP/php-coverage" 44.05 javascript: name: JavaScript @@ -82,6 +95,14 @@ 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: app - run: node tests/run-js.mjs + run: | + ../localbase/tests/coverage/node_modules/.bin/c8 \ + --all \ + '--include=js/**/*.js' \ + --check-coverage \ + --lines=25.03 \ + node tests/run-js.mjs diff --git a/tests/js/frontend-smoke.mjs b/tests/js/frontend-smoke.mjs index 07931b0..97ae8f5 100644 --- a/tests/js/frontend-smoke.mjs +++ b/tests/js/frontend-smoke.mjs @@ -1,4 +1,5 @@ import {readFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; import {runInNewContext} from 'node:vm'; const calendarSource=readFileSync(new URL('../../js/components/month-calendar.js',import.meta.url),'utf8'); @@ -10,7 +11,7 @@ const sources=['models/room.js','models/booking.js','repositories/room-repositor for(const contract of ['class Room extends BaseModel','class Booking extends BaseModel','class RoomRepository extends BaseRepository','this.post(\'/api/bookings\'','class MonthCalendar','class BookingDialog','class BookingWorkflow','class RoomSettings','class RoomWorkflow','adroom:add-booking','adr-admin-room-body','canManageRooms','window.confirm','this.title = String','title: String(values.get']) if(!sources.includes(contract)) throw new Error(`Frontendvertrag fehlt: ${contract}`); for(const contract of ['const sequence = ++loadSequence','if (sequence !== loadSequence) return;','if (sequence === loadSequence) notice.error','let month = formatMonth(new Date())']) if(!sources.includes(contract)) throw new Error(`Monatsladevertrag fehlt: ${contract}`); for(const contract of ['class BookingTimeline','adr-day-schedule','gridTemplateRows = this.timeline.rows(points)','gridRow = `${this.timeline.line','points(bookings)','rows(points)']) if(!sources.includes(contract)) throw new Error(`Gemeinsamer Zeitachsenvertrag fehlt: ${contract}`); -const context={window:{},Date,String,Set,Math}; runInNewContext(timelineSource,context); runInNewContext(calendarSource,context); const calculator=new context.window.AdRoom.BookingTimeline(); +const context={window:{},Date,String,Set,Math}; runInNewContext(timelineSource,context,{filename:fileURLToPath(new URL('../../js/modules/booking-timeline.js',import.meta.url))}); runInNewContext(calendarSource,context,{filename:fileURLToPath(new URL('../../js/components/month-calendar.js',import.meta.url))}); const calculator=new context.window.AdRoom.BookingTimeline(); const timeline=calculator.points([ {startsAt:'2026-07-13T08:00:00',endsAt:'2026-07-13T09:00:00'}, {startsAt:'2026-07-13T10:00:00',endsAt:'2026-07-13T11:00:00'}, @@ -18,14 +19,14 @@ const timeline=calculator.points([ if(timeline.join(',')!=='360,480,540,600,660,1260') throw new Error(`Gemeinsame Zeitachse ist falsch: ${timeline.join(',')}`); const rows=calculator.rows(timeline); if(calculator.line(timeline,600)!==4||(rows.match(/minmax\(/g)||[]).length!==timeline.length-1||!rows.includes(', auto)')) throw new Error('Buchungspositionen werden nicht auf flexible gemeinsame Zeitzeilen abgebildet.'); -const workflowContext={window:{confirm:()=>true}}; runInNewContext(workflowSource,workflowContext); const calls=[]; +const workflowContext={window:{confirm:()=>true}}; runInNewContext(workflowSource,workflowContext,{filename:fileURLToPath(new URL('../../js/modules/booking-workflow.js',import.meta.url))}); const calls=[]; const workflow=new workflowContext.window.AdRoom.BookingWorkflow({ repository:{createBooking:async(payload)=>calls.push(['create',payload]),updateBooking:async(id,payload)=>calls.push(['update',id,payload]),deleteBooking:async(id)=>calls.push(['delete',id])}, notice:{success:(message)=>calls.push(['success',message]),error:(error,message)=>calls.push(['error',message])},dialog:{close:()=>calls.push(['close'])},reload:async()=>calls.push(['reload']), }); await workflow.save({id:0,payload:{title:'Team'}}); await workflow.save({id:7,payload:{title:'Sitzung'}}); await workflow.remove({id:7}); if(calls.filter(call=>call[0]==='create').length!==1||calls.filter(call=>call[0]==='update').length!==1||calls.filter(call=>call[0]==='delete').length!==1) throw new Error('Buchungsworkflow unterscheidet Anlegen, Bearbeiten und Löschen nicht korrekt.'); -const roomWorkflowContext={window:{confirm:()=>true}}; runInNewContext(roomWorkflowSource,roomWorkflowContext); const roomCalls=[]; +const roomWorkflowContext={window:{confirm:()=>true}}; runInNewContext(roomWorkflowSource,roomWorkflowContext,{filename:fileURLToPath(new URL('../../js/modules/room-workflow.js',import.meta.url))}); const roomCalls=[]; const roomWorkflow=new roomWorkflowContext.window.AdRoom.RoomWorkflow({ repository:{createRoom:async(payload)=>roomCalls.push(['create',payload]),updateRoom:async(id,payload)=>roomCalls.push(['update',id,payload]),deleteRoom:async(id)=>roomCalls.push(['delete',id])}, notice:{success:(message)=>roomCalls.push(['success',message]),error:(error,message)=>roomCalls.push(['error',message])},reload:async()=>roomCalls.push(['reload']),