#!/usr/bin/env php data['status'] ?? 500; fwrite(STDERR, "[FAIL] {$context}: {$res->code} {$res->message} ({$status})\n"); exit(1); } return $res; } require __DIR__ . '/wp_emulation.php'; $health = unwrap(request('GET', '/calendar/v1/health'), 'health'); assert_true(($health['status'] ?? '') === 'ok', 'health status should be ok'); $GLOBALS['wp_user'] = [ 'id' => 0, 'email' => '', 'caps' => [], ]; $unauthCreate = request('POST', '/calendar/v1/events', [ 'title' => 'No Auth Event', 'start_datetime' => '2026-05-01T09:00:00+01:00', 'end_datetime' => '2026-05-01T10:00:00+01:00', ]); assert_true($unauthCreate instanceof WP_Error, 'unauthenticated event create should be denied'); assert_true(($unauthCreate->data['status'] ?? 0) === 403, 'unauthenticated event create should return 403'); $GLOBALS['wp_user'] = [ 'id' => 1, 'email' => 'admin@example.test', 'caps' => ['manage_options', 'edit_posts'], ]; $settingsPatch = unwrap( request('PATCH', '/calendar/v1/settings', ['caldav_calendar_name' => 'Calendar E2E']), 'patch settings' ); assert_true(($settingsPatch['data']['caldav_calendar_name'] ?? '') === 'Calendar E2E', 'settings patch must persist'); $settingsGet = unwrap(request('GET', '/calendar/v1/settings'), 'get settings'); assert_true(($settingsGet['data']['caldav_calendar_name'] ?? '') === 'Calendar E2E', 'settings get should reflect patch'); $register = unwrap( request('POST', '/calendar/v1/users/register', ['email' => 'demo@example.test', 'password' => 'pass12345', 'debug_tokens' => true]), 'register user' ); $verifyToken = (string) ($register['data']['verify_token'] ?? ''); assert_true($verifyToken !== '', 'register should issue verify token'); $userId = (int) (($register['data']['user']['id'] ?? 0)); assert_true($userId > 0, 'register should create user id'); $loginPending = request('POST', '/calendar/v1/users/login', ['email' => 'demo@example.test', 'password' => 'pass12345']); assert_true($loginPending instanceof WP_Error, 'pending user login should fail'); assert_true(($loginPending->data['status'] ?? 0) === 401, 'pending user login should return 401'); $verified = unwrap(request('POST', '/calendar/v1/users/verify', ['token' => $verifyToken]), 'verify email'); assert_true(str_contains((string) ($verified['data']['message'] ?? ''), 'admin will review'), 'verify should return approval message'); $approved = unwrap(request('PATCH', '/calendar/v1/admin/users/' . $userId . '/approve', []), 'approve user'); assert_true((string) ($approved['data']['account_status'] ?? '') === 'active', 'approved user status should be active'); $loginActive = unwrap( request('POST', '/calendar/v1/users/login', ['email' => 'demo@example.test', 'password' => 'pass12345']), 'active user login' ); assert_true((bool) (($loginActive['data']['ok'] ?? false) === true), 'active user login should succeed'); $resetReq = unwrap( request('POST', '/calendar/v1/users/password/request', ['email' => 'demo@example.test', 'debug_tokens' => true]), 'request reset' ); $resetToken = (string) ($resetReq['data']['reset_token'] ?? ''); assert_true($resetToken !== '', 'password reset request should return reset token'); $resetDone = unwrap( request('POST', '/calendar/v1/users/password/reset', ['token' => $resetToken, 'new_password' => 'newpass123']), 'reset password' ); assert_true((bool) (($resetDone['data']['ok'] ?? false) === true), 'password reset should succeed'); $loginNewPass = unwrap( request('POST', '/calendar/v1/users/login', ['email' => 'demo@example.test', 'password' => 'newpass123']), 'login with new password' ); assert_true((bool) (($loginNewPass['data']['ok'] ?? false) === true), 'login should work after password reset'); $resetReuse = request('POST', '/calendar/v1/users/password/reset', ['token' => $resetToken, 'new_password' => 'anotherpass123']); assert_true($resetReuse instanceof WP_Error, 'password reset token should be single-use'); assert_true(($resetReuse->data['status'] ?? 0) === 422, 'reused reset token should return 422'); $usersList = unwrap(request('GET', '/calendar/v1/admin/users'), 'list users'); assert_true(count((array) ($usersList['data'] ?? [])) >= 1, 'admin users list should return at least one row'); $removeUser = unwrap(request('DELETE', '/calendar/v1/admin/users/' . $userId), 'remove user'); assert_true((bool) (($removeUser['data']['deleted'] ?? false) === true), 'admin remove user should succeed'); $loginRemoved = request('POST', '/calendar/v1/users/login', ['email' => 'demo@example.test', 'password' => 'newpass123']); assert_true($loginRemoved instanceof WP_Error, 'removed user login should fail'); assert_true(($loginRemoved->data['status'] ?? 0) === 401, 'removed user login should return 401'); $created = unwrap( request('POST', '/calendar/v1/events', [ 'title' => 'Emulated Recurring Event', 'description' => 'e2e', 'location' => 'Room 1', 'category' => 'Test', 'all_day_event' => false, 'start_datetime' => '2026-04-01T10:00:00+01:00', 'end_datetime' => '2026-04-01T11:00:00+01:00', 'repeat_type' => 'daily', 'repeat_interval' => 1, 'repeat_range_mode' => 'count', 'repeat_count' => 3, ]), 'create event' ); $eventId = (int) ($created['data']['id'] ?? 0); assert_true($eventId > 0, 'event id should be generated'); $invalidEvent = request('POST', '/calendar/v1/events', [ 'title' => 'Bad Range', 'start_datetime' => '2026-04-12T12:00:00+01:00', 'end_datetime' => '2026-04-12T11:00:00+01:00', ]); assert_true($invalidEvent instanceof WP_Error, 'invalid event with end before start should fail'); assert_true(($invalidEvent->data['status'] ?? 0) === 422, 'invalid event should return 422'); $public = unwrap(request('GET', '/calendar/v1/public/events', ['view' => 'month', 'date' => '2026-04-01']), 'public events'); $countBefore = (int) ($public['meta']['count'] ?? 0); assert_true($countBefore >= 3, 'public month view should include recurrence occurrences'); $preview = unwrap( request('POST', '/calendar/v1/events/preview-occurrences', [ 'event' => [ 'title' => 'Preview Event', 'start_datetime' => '2026-04-05T08:00:00+01:00', 'end_datetime' => '2026-04-05T09:00:00+01:00', 'repeat_type' => 'weekly', 'repeat_interval' => 1, 'repeat_range_mode' => 'count', 'repeat_count' => 4, ], 'from' => '2026-04-01', 'months' => 1, ]), 'preview occurrences' ); assert_true((int) ($preview['meta']['count'] ?? 0) >= 1, 'preview occurrences should return generated items'); $previewBad = request('POST', '/calendar/v1/events/preview-occurrences', [ 'event' => [ 'title' => 'Bad Preview', 'start_datetime' => '2026-04-05T10:00:00+01:00', 'end_datetime' => '2026-04-05T09:00:00+01:00', 'repeat_type' => 'weekly', ], 'from' => '2026-04-01', 'months' => 1, ]); assert_true($previewBad instanceof WP_Error, 'preview occurrences should validate invalid event range'); assert_true(($previewBad->data['status'] ?? 0) === 422, 'invalid preview payload should return 422'); $deleteOne = unwrap( request('DELETE', '/calendar/v1/events/' . $eventId . '/occurrences/' . rawurlencode('2026-04-02T10:00:00+01:00')), 'delete occurrence' ); assert_true(($deleteOne['data']['deleted'] ?? false) === true, 'delete occurrence should succeed'); $deleteSameAgain = unwrap( request('DELETE', '/calendar/v1/events/' . $eventId . '/occurrences/' . rawurlencode('2026-04-02T10:00:00+01:00')), 'delete same occurrence again' ); assert_true(($deleteSameAgain['data']['deleted'] ?? false) === true, 'repeat delete occurrence should be idempotent success'); $occurrences = unwrap( request('GET', '/calendar/v1/events/' . $eventId . '/occurrences', ['from' => '2026-04-01', 'months' => 1]), 'event occurrences' ); assert_true((int) ($occurrences['meta']['count'] ?? 0) >= 2, 'event occurrences endpoint should return remaining recurrence instances'); $publicAfter = unwrap(request('GET', '/calendar/v1/public/events', ['view' => 'month', 'date' => '2026-04-01']), 'public events after delete'); $days = []; foreach (($publicAfter['data'] ?? []) as $item) { $days[] = substr((string) ($item['occurrence_start'] ?? ''), 0, 10); } assert_true(!in_array('2026-04-02', $days, true), 'deleted single occurrence should be excluded'); $ics = unwrap(request('GET', '/calendar/v1/public/ics'), 'public ics'); $icsBody = (string) ($ics['data'] ?? ''); assert_true(str_contains($icsBody, 'BEGIN:VCALENDAR'), 'ics response should include vcalendar envelope'); assert_true(str_contains($icsBody, 'RRULE:'), 'ics response should include recurrence rule'); assert_true(str_contains($icsBody, 'EXDATE'), 'ics response should include deleted occurrence as exdate'); $monthlyOrdinal = unwrap( request('POST', '/calendar/v1/events', [ 'title' => 'Monthly Ordinal', 'description' => 'monthly weekday parity', 'location' => 'Room 2', 'category' => 'Monthly', 'all_day_event' => false, 'start_datetime' => '2026-04-05T09:30:00+01:00', 'end_datetime' => '2026-04-05T10:30:00+01:00', 'repeat_type' => 'monthly', 'repeat_interval' => 1, 'repeat_nth_mode' => 'weekday_of_month', 'repeat_nth_pos' => -1, 'repeat_nth_weekday' => 0, 'repeat_range_mode' => 'count', 'repeat_count' => 3, ]), 'create monthly ordinal event' ); $monthlyEventId = (int) ($monthlyOrdinal['data']['id'] ?? 0); assert_true($monthlyEventId > 0, 'monthly ordinal event id should be generated'); $monthlyOcc = unwrap( request('GET', '/calendar/v1/events/' . $monthlyEventId . '/occurrences', ['from' => '2026-04-01', 'months' => 3]), 'list monthly ordinal occurrences' ); $monthlyDays = array_map( static fn(array $row): string => substr((string) ($row['occurrence_start'] ?? ''), 0, 10), (array) ($monthlyOcc['data'] ?? []) ); assert_true(in_array('2026-04-26', $monthlyDays, true), 'monthly ordinal should include last Sunday of April 2026'); assert_true(in_array('2026-05-31', $monthlyDays, true), 'monthly ordinal should include last Sunday of May 2026'); $resources = unwrap(request('GET', '/calendar/v1/caldav/resources'), 'list caldav resources'); $resourceList = (array) ($resources['data'] ?? []); assert_true(count($resourceList) >= 1, 'caldav resources should list at least one object'); $firstResource = (string) ($resourceList[0]['resource'] ?? ''); assert_true($firstResource !== '', 'caldav resource should have object name'); $getObject = unwrap(request('GET', '/calendar/v1/caldav/object/' . rawurlencode($firstResource)), 'get caldav object'); $firstEtag = (string) ($getObject['data']['etag'] ?? ''); assert_true($firstEtag !== '', 'caldav object should include etag'); $newResource = 'new-fixture-event.ics'; $newIcs = implode("\r\n", [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Calendar Plugin E2E//EN', 'BEGIN:VEVENT', 'UID:new-fixture-event@calendar-plugin', 'SUMMARY:Fixture PUT Event', 'DESCRIPTION:Created via CalDAV PUT', 'DTSTART;TZID=Europe/London:20260410T093000', 'DTEND;TZID=Europe/London:20260410T103000', 'END:VEVENT', 'END:VCALENDAR', '', ]); $createdViaPut = unwrap( request('PUT', '/calendar/v1/caldav/object/' . $newResource, ['ics' => $newIcs], ['If-None-Match' => '*']), 'caldav put create' ); assert_true((bool) (($createdViaPut['data']['created'] ?? false) === true), 'caldav put should create object with if-none-match'); $duplicateBlocked = request( 'PUT', '/calendar/v1/caldav/object/' . $newResource, ['ics' => $newIcs], ['If-None-Match' => '*'] ); assert_true($duplicateBlocked instanceof WP_Error, 'duplicate create should fail on if-none-match precondition'); assert_true(($duplicateBlocked->data['status'] ?? 0) === 412, 'duplicate create should return 412'); $updatedIcs = implode("\r\n", [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Calendar Plugin E2E//EN', 'BEGIN:VEVENT', 'UID:new-fixture-event@calendar-plugin', 'SUMMARY:Fixture PUT Event Updated', 'DESCRIPTION:Updated via CalDAV PUT', 'DTSTART;TZID=Europe/London:20260410T100000', 'DTEND;TZID=Europe/London:20260410T110000', 'END:VEVENT', 'END:VCALENDAR', '', ]); $staleUpdate = request( 'PUT', '/calendar/v1/caldav/object/' . $newResource, ['ics' => $updatedIcs], ['If-Match' => '"stale-etag"'] ); assert_true($staleUpdate instanceof WP_Error, 'stale etag update should fail with precondition error'); assert_true(($staleUpdate->data['status'] ?? 0) === 412, 'stale etag update should return 412'); $currentObj = unwrap(request('GET', '/calendar/v1/caldav/object/' . $newResource), 'get new resource object'); $currentEtag = (string) ($currentObj['data']['etag'] ?? ''); assert_true($currentEtag !== '', 'newly created object should have etag'); $updatedOk = unwrap( request('PUT', '/calendar/v1/caldav/object/' . $newResource, ['ics' => $updatedIcs], ['If-Match' => $currentEtag]), 'caldav put update with etag' ); assert_true((bool) (($updatedOk['data']['created'] ?? true) === false), 'caldav put with if-match should update existing object'); $multi = unwrap( request('POST', '/calendar/v1/caldav/multiget', ['resources' => [$newResource, 'missing-event.ics']]), 'caldav multiget' ); $multiData = (array) ($multi['data'] ?? []); assert_true(count($multiData) === 2, 'multiget should return one row per requested resource'); assert_true((int) ($multiData[0]['status'] ?? 0) === 200, 'multiget should return 200 for existing resource'); assert_true((int) ($multiData[1]['status'] ?? 0) === 404, 'multiget should return 404 for missing resource'); $deletedObj = unwrap(request('DELETE', '/calendar/v1/caldav/object/' . $newResource), 'caldav delete object'); assert_true((bool) (($deletedObj['data']['deleted'] ?? false) === true), 'caldav delete should remove object'); $deletedNotFound = request('GET', '/calendar/v1/caldav/object/' . $newResource); assert_true($deletedNotFound instanceof WP_Error, 'deleted caldav object should no longer resolve'); assert_true(($deletedNotFound->data['status'] ?? 0) === 404, 'deleted caldav object should return 404'); $sluggedSettings = unwrap( request('PATCH', '/calendar/v1/settings', ['url_slug' => 'events']), 'patch slug setting' ); assert_true(($sluggedSettings['data']['url_slug'] ?? '') === 'events', 'url_slug setting should persist'); $sluggedCalendarHtml = do_shortcode('[calendar]'); assert_true(str_contains($sluggedCalendarHtml, 'href="/events/calendar.ics"'), 'calendar shortcode should use slugged ICS path'); assert_true(str_contains($sluggedCalendarHtml, 'href="/events/caldav/"'), 'calendar shortcode should use slugged CalDAV path'); $sidebarHtml = do_shortcode('[calendar_sidebar_upcoming]'); assert_true(str_contains($sidebarHtml, '