calendar-plugin/compatibility-layer/e2e_wp_emulation.php

360 lines
16 KiB
PHP
Executable File

#!/usr/bin/env php
<?php
declare(strict_types=1);
function assert_true(bool $cond, string $msg): void
{
if (!$cond) {
fwrite(STDERR, "[FAIL] {$msg}\n");
exit(1);
}
}
function request(string $method, string $route, array $json = [], array $headers = []): array|WP_Error
{
$req = new WP_REST_Request($method, $route, $json, $headers);
return rest_do_request($req);
}
function unwrap(array|WP_Error $res, string $context): array
{
if ($res instanceof WP_Error) {
$status = $res->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');
unwrap(
request('POST', '/calendar/v1/events', [
'title' => 'Sidebar Weekday Event',
'description' => 'sidebar date label',
'start_datetime' => '2026-05-03T09:00:00+01:00',
'end_datetime' => '2026-05-03T10:00:00+01:00',
'repeat_type' => 'none',
'repeat_interval' => 1,
'repeat_range_mode' => 'none',
]),
'create sidebar weekday event'
);
$sidebarHtml = do_shortcode('[calendar_sidebar_upcoming]');
assert_true(str_contains($sidebarHtml, '<div class="calendar-plugin-shell"'), 'sidebar shortcode should render');
assert_true(str_contains($sidebarHtml, 'Sunday 3 May 2026'), 'sidebar shortcode should include weekday in event date');
echo "[PASS] compatibility-layer e2e wp emulation\n";