360 lines
16 KiB
PHP
Executable File
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";
|