diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/DAV.php @@ -0,0 +1,508 @@ + 'urn:ietf:params:xml:ns:caldav', + self::TYPE_VTODO => 'urn:ietf:params:xml:ns:caldav', + self::TYPE_VCARD => 'urn:ietf:params:xml:ns:carddav', + ]; + + protected $url; + protected $user; + protected $password; + protected $responseHeaders = []; + + /** + * Object constructor + */ + public function __construct($user, $password) + { + $this->url = \config('dav.uri'); + $this->user = $user; + $this->password = $password; + } + + /** + * Discover DAV home (root) collection of a specified type. + * + * @param string $component Component to filter by (VEVENT, VTODO, VCARD) + * + * @return string|false Home collection location or False on error + */ + public function discover(string $component = self::TYPE_VEVENT) + { + $roots = [ + self::TYPE_VEVENT => 'calendars', + self::TYPE_VTODO => 'calendars', + self::TYPE_VCARD => 'addressbooks', + ]; + + $homes = [ + self::TYPE_VEVENT => 'calendar-home-set', + self::TYPE_VTODO => 'calendar-home-set', + self::TYPE_VCARD => 'addressbook-home-set', + ]; + + $path = parse_url($this->url, PHP_URL_PATH); + + $body = '' + . '' + . '' + . '' + . '' + . ''; + + // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) + $headers = ['Depth' => 1, 'Prefer' => 'return-minimal']; + + $response = $this->request('/' . $roots[$component], 'PROPFIND', $body, $headers); + + if (empty($response)) { + \Log::error("Failed to get current-user-principal for {$component} from the DAV server."); + return false; + } + + $elements = $response->getElementsByTagName('response'); + + foreach ($elements as $element) { + foreach ($element->getElementsByTagName('prop') as $prop) { + $principal_href = $prop->nodeValue; + break; + } + } + + if (empty($principal_href)) { + \Log::error("No principal on the DAV server."); + return false; + } + + if ($path && strpos($principal_href, $path) === 0) { + $principal_href = substr($principal_href, strlen($path)); + } + + $body = '' + . '' + . '' + . '' + . '' + . ''; + + $response = $this->request($principal_href, 'PROPFIND', $body); + + if (empty($response)) { + \Log::error("Failed to get homes for {$component} from the DAV server."); + return false; + } + + $root_href = false; + $elements = $response->getElementsByTagName('response'); + + foreach ($elements as $element) { + foreach ($element->getElementsByTagName('prop') as $prop) { + $root_href = $prop->nodeValue; + break; + } + } + + if (!empty($root_href)) { + if ($path && strpos($root_href, $path) === 0) { + $root_href = substr($root_href, strlen($path)); + } + } + + return $root_href; + } + + /** + * Check if we can connect to the DAV server + * + * @return bool True on success, False otherwise + */ + public static function healthcheck(): bool + { + // TODO + return true; + } + + /** + * Get list of folders of specified type. + * + * @param string $component Component to filter by (VEVENT, VTODO, VCARD) + * + * @return false|array List of folders' metadata or False on error + */ + public function listFolders(string $component) + { + $root_href = $this->discover($component); + + if ($root_href === false) { + return false; + } + + $ns = 'xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"'; + $props = ''; + + if ($component != self::TYPE_VCARD) { + $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/" xmlns:k="Kolab:"'; + $props = '' + . '' + . ''; + } + + $body = '' + . '' + . '' + . '' + . '' + . '' + . $props + . '' + . ''; + + // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) + $headers = ['Depth' => 1, 'Prefer' => 'return-minimal']; + + $response = $this->request($root_href, 'PROPFIND', $body, $headers); + + if (empty($response)) { + \Log::error("Failed to get folders list from the DAV server."); + return false; + } + + $folders = []; + + foreach ($response->getElementsByTagName('response') as $element) { + $folder = DAV\Folder::fromDomElement($element); + + // Note: Addressbooks don't have 'type' specified + if (($component == self::TYPE_VCARD && in_array('addressbook', $folder->types)) + || in_array($component, $folder->components) + ) { + $folders[] = $folder; + } + } + + return $folders; + } + + /** + * Create a DAV object in a folder + * + * @param DAV\CommonObject $object Object + * + * @return false|DAV\CommonObject Object on success, False on error + */ + public function create(DAV\CommonObject $object) + { + $headers = ['Content-Type' => $object->contentType]; + + $response = $this->request($object->href, 'PUT', $object, $headers); + + if ($response !== false) { + if ($etag = $this->responseHeaders['etag']) { + if (preg_match('|^".*"$|', $etag)) { + $etag = substr($etag, 1, -1); + } + + $object->etag = $etag; + } + + return $object; + } + + return false; + } + + /** + * Update a DAV object in a folder + * + * @param DAV\CommonObject $object Object + * + * @return false|DAV\CommonObject Object on success, False on error + */ + public function update(DAV\CommonObject $object) + { + return $this->create($object); + } + + /** + * Delete a DAV object from a folder + * + * @param string $location Object location + * + * @return bool True on success, False on error + */ + public function delete(string $location) + { + $response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']); + + return $response !== false; + } + + /** + * Get all properties of a folder. + * + * @param string $location Object location + * + * @return false|DAV\Folder Folder metadata or False on error + */ + public function folderInfo(string $location) + { + $body = '' + . '' + . '' + . ''; + + // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) + $headers = ['Depth' => 1, 'Prefer' => 'return-minimal']; + + $response = $this->request($location, 'PROPFIND', $body, $headers); + + if (!empty($response) && ($element = $response->getElementsByTagName('response')->item(0))) { + return DAV\Folder::fromDomElement($element); + } + + return false; + } + + /** + * Search DAV objects in a folder. + * + * @param string $location Folder location + * @param string $component Object type (VEVENT, VTODO, VCARD) + * + * @return false|array Objects metadata on success, False on error + */ + public function search(string $location, string $component) + { + $queries = [ + self::TYPE_VEVENT => 'calendar-query', + self::TYPE_VTODO => 'calendar-query', + self::TYPE_VCARD => 'addressbook-query', + ]; + + $filter = ''; + if ($component != self::TYPE_VCARD) { + $filter = '' + . '' + . ''; + } + + // TODO: Make filter an argument of this function to build all kind of queries. + // It probably should be a separate object e.g. DAV\Filter. + // TODO: List of object props to return should also be an argument, so we not only + // could fetch "an index" but also any of object's data. + + $body = '' + .' ' + . '' + . '' + . '' + . ($filter ? "$filter" : '') + . ''; + + $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); + + if (empty($response)) { + \Log::error("Failed to get objects from the DAV server."); + return false; + } + + $objects = []; + + foreach ($response->getElementsByTagName('response') as $element) { + $objects[] = $this->objectFromElement($element, $component); + } + + return $objects; + } + + /** + * Fetch DAV objects data from a folder + * + * @param string $location Folder location + * @param string $component Object type (VEVENT, VTODO, VCARD) + * @param array $hrefs List of objects' locations to fetch (empty for all objects) + * + * @return false|array Objects metadata on success, False on error + */ + public function getObjects(string $location, string $component, array $hrefs = []) + { + if (empty($hrefs)) { + return []; + } + + $body = ''; + foreach ($hrefs as $href) { + $body .= '' . $href . ''; + } + + $queries = [ + self::TYPE_VEVENT => 'calendar-multiget', + self::TYPE_VTODO => 'calendar-multiget', + self::TYPE_VCARD => 'addressbook-multiget', + ]; + + $types = [ + self::TYPE_VEVENT => 'calendar-data', + self::TYPE_VTODO => 'calendar-data', + self::TYPE_VCARD => 'address-data', + ]; + + $body = '' + .' ' + . '' + . '' + . '' + . '' + . $body + . ''; + + $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); + + if (empty($response)) { + \Log::error("Failed to get objects from the DAV server."); + return false; + } + + $objects = []; + + foreach ($response->getElementsByTagName('response') as $element) { + $objects[] = $this->objectFromElement($element, $component); + } + + return $objects; + } + + /** + * Parse XML content + */ + protected function parseXML($xml) + { + $doc = new \DOMDocument('1.0', 'UTF-8'); + + if (stripos($xml, 'loadXML($xml)) { + throw new \Exception("Failed to parse XML"); + } + + $doc->formatOutput = true; + } + + return $doc; + } + + /** + * Parse request/response body for debug purposes + */ + protected function debugBody($body, $headers) + { + $head = ''; + + foreach ($headers as $header_name => $header_value) { + if (is_array($header_value)) { + $header_value = implode("\n\t", $header_value); + } + + $head .= "{$header_name}: {$header_value}\n"; + } + + if (stripos($body, 'formatOutput = true; + $doc->preserveWhiteSpace = false; + + if (!$doc->loadXML($body)) { + throw new \Exception("Failed to parse XML"); + } + + $body = $doc->saveXML(); + } + + return $head . "\n" . rtrim($body); + } + + /** + * Create DAV\CommonObject from a DOMElement + */ + protected function objectFromElement($element, $component) + { + switch ($component) { + case self::TYPE_VEVENT: + $object = DAV\Vevent::fromDomElement($element); + break; + case self::TYPE_VTODO: + $object = DAV\Vtodo::fromDomElement($element); + break; + case self::TYPE_VCARD: + $object = DAV\Vcard::fromDomElement($element); + break; + default: + throw new \Exception("Unknown component: {$component}"); + } + + return $object; + } + + /** + * Execute HTTP request to a DAV server + */ + protected function request($path, $method, $body = '', $headers = []) + { + $debug = \config('app.debug'); + $url = $this->url; + + $this->responseHeaders = []; + + if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && strpos($path, $rootPath) === 0) { + $path = substr($path, strlen($rootPath)); + } + + $url .= $path; + + $client = Http::withBasicAuth($this->user, $this->password); + // $client = Http::withToken($token); // Bearer token + + if ($body) { + if (!isset($headers['Content-Type'])) { + $headers['Content-Type'] = 'application/xml; charset=utf-8'; + } + + $client->withBody($body, $headers['Content-Type']); + } + + if (!empty($headers)) { + $client->withHeaders($headers); + } + + if ($debug) { + \Log::debug("C: {$method}: {$url}\n" . $this->debugBody($body, $headers)); + } + + $response = $client->send($method, $url); + + $body = $response->body(); + $code = $response->status(); + + if ($debug) { + \Log::debug("S: [{$code}]\n" . $this->debugBody($body, $response->headers())); + } + + // Throw an exception if a client or server error occurred... + $response->throw(); + + $this->responseHeaders = $response->headers(); + + return $this->parseXML($body); + } +} diff --git a/src/app/Backends/DAV/CommonObject.php b/src/app/Backends/DAV/CommonObject.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/DAV/CommonObject.php @@ -0,0 +1,58 @@ +getElementsByTagName('href')->item(0)) { + $object->href = $href->nodeValue; + + // Extract UID from the URL + $href_parts = explode('/', $object->href); + $object->uid = preg_replace('/\.[a-z]+$/', '', $href_parts[count($href_parts)-1]); + } + + if ($etag = $element->getElementsByTagName('getetag')->item(0)) { + $object->etag = $etag->nodeValue; + if (preg_match('|^".*"$|', $object->etag)) { + $object->etag = substr($object->etag, 1, -1); + } + } + + return $object; + } + + /** + * Create string representation of the DAV object + * + * @return string + */ + public function __toString() + { + return ''; + } +} diff --git a/src/app/Backends/DAV/Folder.php b/src/app/Backends/DAV/Folder.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/DAV/Folder.php @@ -0,0 +1,77 @@ +getElementsByTagName('href')->item(0)) { + $folder->href = $href->nodeValue; + } + + if ($color = $element->getElementsByTagName('calendar-color')->item(0)) { + if (preg_match('/^#[0-9a-fA-F]{6,8}$/', $color->nodeValue)) { + $folder->color = substr($color->nodeValue, 1); + } + } + + if ($name = $element->getElementsByTagName('displayname')->item(0)) { + $folder->name = $name->nodeValue; + } + + if ($ctag = $element->getElementsByTagName('getctag')->item(0)) { + $folder->ctag = $ctag->nodeValue; + } + + $components = []; + if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) { + foreach ($set_element->getElementsByTagName('comp') as $comp) { + $components[] = $comp->attributes->getNamedItem('name')->nodeValue; + } + } + + $types = []; + if ($type_element = $element->getElementsByTagName('resourcetype')->item(0)) { + foreach ($type_element->childNodes as $node) { + if ($node->nodeType == XML_ELEMENT_NODE) { + $_type = explode(':', $node->nodeName); + $types[] = count($_type) > 1 ? $_type[1] : $_type[0]; + } + } + } + + $folder->types = $types; + $folder->components = $components; + + return $folder; + } +} diff --git a/src/app/Backends/DAV/Vcard.php b/src/app/Backends/DAV/Vcard.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/DAV/Vcard.php @@ -0,0 +1,49 @@ +getElementsByTagName('address-data')->item(0)) { + $object->fromVcard($data->nodeValue); + } + + return $object; + } + + /** + * Set object properties from a vcard + * + * @param string $vcard vCard string + */ + protected function fromVcard(string $vcard): void + { + // TODO + } + + /** + * Create string representation of the DAV object (vcard) + * + * @return string + */ + public function __toString() + { + // TODO: This will be needed when we want to create/update objects + return ''; + } +} diff --git a/src/app/Backends/DAV/Vevent.php b/src/app/Backends/DAV/Vevent.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/DAV/Vevent.php @@ -0,0 +1,282 @@ +getElementsByTagName('calendar-data')->item(0)) { + $object->fromIcal($data->nodeValue); + } + + return $object; + } + + /** + * Set object properties from an iCalendar + * + * @param string $ical iCalendar string + */ + protected function fromIcal(string $ical): void + { + $options = VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES; + $vobject = VObject\Reader::read($ical, $options); + + if ($vobject->name != 'VCALENDAR') { + return; + } + + $selfType = strtoupper(class_basename(get_class($this))); + + foreach ($vobject->getComponents() as $component) { + if ($component->name == $selfType) { + $this->fromVObject($component); + return; + } + } + } + + /** + * Set object properties from a Sabre/VObject component object + * + * @param VObject\Component $vobject Sabre/VObject component + */ + protected function fromVObject(VObject\Component $vobject): void + { + $string_properties = [ + 'COMMENT', + 'DESCRIPTION', + 'LOCATION', + 'SEQUENCE', + 'STATUS', + 'SUMMARY', + 'TRANSP', + 'UID', + 'URL', + ]; + + // map string properties + foreach ($string_properties as $prop) { + if (isset($vobject->{$prop})) { + $key = Str::camel(strtolower($prop)); + $this->{$key} = (string) $vobject->{$prop}; + } + } + + // map other properties + foreach ($vobject->children() as $prop) { + if (!($prop instanceof VObject\Property)) { + continue; + } + + switch ($prop->name) { + case 'DTSTART': + case 'DTEND': + case 'DUE': + case 'CREATED': + case 'LAST-MODIFIED': + case 'DTSTAMP': + $key = Str::camel(strtolower($prop->name)); + // These are of type Sabre\VObject\Property\ICalendar\DateTime + $this->{$key} = $prop; + break; + + case 'RRULE': + $params = !empty($this->recurrence) ? $this->recurrence : []; + + foreach ($prop->getParts() as $k => $v) { + $params[Str::camel(strtolower($k))] = is_array($v) ? implode(',', $v) : $v; + } + + if (!empty($params['until'])) { + $params['until'] = new \DateTime($params['until']); + } + + if (empty($params['interval'])) { + $params['interval'] = 1; + } + + $this->recurrence = array_filter($params); + break; + + case 'EXDATE': + case 'RDATE': + $key = strtolower($prop->name); + $dates = []; // TODO + + if (!empty($this->recurrence[$key])) { + $this->recurrence[$key] = array_merge($this->recurrence[$key], $dates); + } + else { + $this->recurrence[$key] = $dates; + } + + break; + + case 'ATTENDEE': + case 'ORGANIZER': + $attendee = [ + 'rsvp' => false, + 'email' => preg_replace('!^mailto:!i', '', (string) $prop), + ]; + + $attendeeProps = ['CN', 'PARTSTAT', 'ROLE', 'CUTYPE', 'RSVP', 'DELEGATED-FROM', 'DELEGATED-TO', + 'SCHEDULE-STATUS', 'SCHEDULE-AGENT', 'SENT-BY']; + + foreach ($prop->parameters() as $name => $value) { + $key = Str::camel(strtolower($name)); + switch ($name) { + case 'RSVP': + $params[$key] = strtolower($value) == 'true'; + break; + case 'CN': + $params[$key] = str_replace('\,', ',', strval($value)); + break; + default: + if (in_array($name, $attendeeProps)) { + $params[$key] = strval($value); + } + break; + } + } + + if ($prop->name == 'ORGANIZER') { + $attendee['role'] = 'ORGANIZER'; + $attendee['partstat'] = 'ACCEPTED'; + + $this->organizer = $attendee; + } + else if (empty($this->organizer) || $attendee['email'] != $this->organizer['email']) { + $this->attendees[] = $attendee; + } + + break; + } + } + + // Check DURATION property if no end date is set + /* + if (empty($this->dtend) && !empty($this->dtstart) && !empty($vobject->DURATION)) { + try { + $duration = new \DateInterval((string) $vobject->DURATION); + $end = clone $this->dtstart; + $end->add($duration); + $this->dtend = $end; + } + catch (\Exception $e) { + // TODO: Error? + } + } + */ + + // Find alarms + foreach ($vobject->select('VALARM') as $valarm) { + $action = 'DISPLAY'; + $trigger = null; + $alarm = []; + + foreach ($valarm->children() as $prop) { + $value = strval($prop); + + switch ($prop->name) { + case 'TRIGGER': + foreach ($prop->parameters as $param) { + if ($param->name == 'VALUE' && $param->getValue() == 'DATE-TIME') { + $trigger = '@' . $prop->getDateTime()->format('U'); + $alarm['trigger'] = $prop->getDateTime(); + } + else if ($param->name == 'RELATED') { + $alarm['related'] = $param->getValue(); + } + } +/* + if (!$trigger && ($values = libcalendaring::parse_alarm_value($value))) { + $trigger = $values[2]; + } +*/ + if (empty($alarm['trigger'])) { + $alarm['trigger'] = rtrim(preg_replace('/([A-Z])0[WDHMS]/', '\\1', $value), 'T'); + // if all 0-values have been stripped, assume 'at time' + if ($alarm['trigger'] == 'P') { + $alarm['trigger'] = 'PT0S'; + } + } + break; + + case 'ACTION': + $action = $alarm['action'] = strtoupper($value); + break; + + case 'SUMMARY': + case 'DESCRIPTION': + case 'DURATION': + $alarm[strtolower($prop->name)] = $value; + break; + + case 'REPEAT': + $alarm['repeat'] = (int) $value; + break; + + case 'ATTENDEE': + $alarm['attendees'][] = preg_replace('!^mailto:!i', '', $value); + break; + } + } + + if ($action != 'NONE') { + if (!empty($alarm['trigger'])) { + $this->valarms[] = $alarm; + } + } + } + } + + /** + * Create string representation of the DAV object (iCalendar) + * + * @return string + */ + public function __toString() + { + // TODO: This will be needed when we want to create/update objects + return ''; + } +} diff --git a/src/app/Backends/DAV/Vtodo.php b/src/app/Backends/DAV/Vtodo.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/DAV/Vtodo.php @@ -0,0 +1,7 @@ +line($exception); + return false; + } + } + private function checkLDAP() { try { @@ -157,7 +169,7 @@ { $result = 0; $steps = [ - 'DB', 'Redis', 'IMAP', 'Roundcube', 'Meet', 'Mollie', 'OpenExchangeRates', + 'DB', 'Redis', 'IMAP', 'Roundcube', 'Meet', 'DAV', 'Mollie', 'OpenExchangeRates', ]; if (\config('app.with_ldap')) { diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -31,6 +31,7 @@ "moontoast/math": "^1.2", "pear/crypt_gpg": "^1.6.6", "predis/predis": "^1.1.10", + "sabre/vobject": "^4.5", "spatie/laravel-translatable": "^5.2", "spomky-labs/otphp": "~10.0.0", "stripe/stripe-php": "^7.29" diff --git a/src/config/dav.php b/src/config/dav.php new file mode 100644 --- /dev/null +++ b/src/config/dav.php @@ -0,0 +1,5 @@ + env('DAV_URI', 'http://kolab/dav'), +]; diff --git a/src/tests/Unit/Backends/DAV/FolderTest.php b/src/tests/Unit/Backends/DAV/FolderTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Backends/DAV/FolderTest.php @@ -0,0 +1,53 @@ + + + + /dav/calendars/user/alec@aphy.io/Default/ + + + + + + + + 1665578572-16 + + + + + + #cccccc + + HTTP/1.1 200 OK + + + +XML; + + $doc = new \DOMDocument('1.0', 'UTF-8'); + $doc->loadXML($xml); + $folder = Folder::fromDomElement($doc->getElementsByTagName('response')->item(0)); + + $this->assertInstanceOf(Folder::class, $folder); + $this->assertSame("/dav/calendars/user/alec@aphy.io/Default/", $folder->href); + $this->assertSame('1665578572-16', $folder->ctag); + $this->assertSame('personal', $folder->name); + $this->assertSame('cccccc', $folder->color); + $this->assertSame(['collection', 'calendar'], $folder->types); + $this->assertSame(['VEVENT', 'VTODO', 'VJOURNAL'], $folder->components); + } +} diff --git a/src/tests/Unit/Backends/DAV/VcardTest.php b/src/tests/Unit/Backends/DAV/VcardTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Backends/DAV/VcardTest.php @@ -0,0 +1,50 @@ + + + + /dav/addressbooks/user/test@test.com/Default/$uid.vcf + + + "d27382e0b401384becb0d5b157d6b73a2c2084a2" + + + HTTP/1.1 200 OK + + + +XML; + + $doc = new \DOMDocument('1.0', 'UTF-8'); + $doc->loadXML($vcard); + $contact = Vcard::fromDomElement($doc->getElementsByTagName('response')->item(0)); + + $this->assertInstanceOf(Vcard::class, $contact); + $this->assertSame('d27382e0b401384becb0d5b157d6b73a2c2084a2', $contact->etag); + $this->assertSame("/dav/addressbooks/user/test@test.com/Default/{$uid}.vcf", $contact->href); + $this->assertSame('text/vcard; charset=utf-8', $contact->contentType); + $this->assertSame($uid, $contact->uid); + + // TODO: Test all supported properties in detail + } +} diff --git a/src/tests/Unit/Backends/DAV/VeventTest.php b/src/tests/Unit/Backends/DAV/VeventTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Backends/DAV/VeventTest.php @@ -0,0 +1,81 @@ + + + + /dav/calendars/user/test@test.com/Default/$uid.ics + + + "d27382e0b401384becb0d5b157d6b73a2c2084a2" + + + HTTP/1.1 200 OK + + + +XML; + + $doc = new \DOMDocument('1.0', 'UTF-8'); + $doc->loadXML($ical); + $event = Vevent::fromDomElement($doc->getElementsByTagName('response')->item(0)); + + $this->assertInstanceOf(Vevent::class, $event); + $this->assertSame('d27382e0b401384becb0d5b157d6b73a2c2084a2', $event->etag); + $this->assertSame("/dav/calendars/user/test@test.com/Default/{$uid}.ics", $event->href); + $this->assertSame('text/calendar; charset=utf-8', $event->contentType); + $this->assertSame($uid, $event->uid); + $this->assertSame('My summary', $event->summary); + $this->assertSame('desc', $event->description); + $this->assertSame('OPAQUE', $event->transp); + + // TODO: Should we make these Sabre\VObject\Property\ICalendar\DateTime properties + $this->assertSame('20221016T103238Z', (string) $event->dtstamp); + $this->assertSame('20221013', (string) $event->dtstart); + + $organizer = [ + 'rsvp' => false, + 'email' => 'organizer@test.com', + 'role' => 'ORGANIZER', + 'partstat' => 'ACCEPTED', + ]; + $this->assertSame($organizer, $event->organizer); + + $recurrence = [ + 'freq' => 'WEEKLY', + 'interval' => 1, + ]; + $this->assertSame($recurrence, $event->recurrence); + + // TODO: Test all supported properties in detail + } +}