diff --git a/lib/DAV/Calendar.php b/lib/DAV/Calendar.php index dbc90f9fae..5d1510ca2f 100644 --- a/lib/DAV/Calendar.php +++ b/lib/DAV/Calendar.php @@ -28,6 +28,8 @@ class Calendar extends ExternalCalendar { private $backend; /** @var Board */ private $board; + /** @var array|null */ + private $acl; public function __construct(string $principalUri, string $calendarUri, Board $board, DeckCalendarBackend $backend) { parent::__construct('deck', $calendarUri); @@ -42,9 +44,22 @@ public function getOwner() { return $this->principalUri; } + /** + * Nextcloud's DAV Schedule Plugin calls this for writable external + * calendars. Deck calendars are not shared scheduling calendars, and Deck + * does not process iTIP/ATTENDEE scheduling data here. + */ + public function isShared(): bool { + return false; + } + public function getACL() { - // the calendar should always have the read and the write-properties permissions - // write-properties is needed to allow the user to toggle the visibility of shared deck calendars + if ($this->acl !== null) { + return $this->acl; + } + + // write-content covers PUT on existing objects but not bind/unbind, so + // CREATE and DELETE are rejected at the ACL layer before any hooks run. $acl = [ [ 'privilege' => '{DAV:}read', @@ -57,8 +72,16 @@ public function getACL() { 'protected' => true, ] ]; + if ($this->backend->checkBoardPermission($this->board->getId(), Acl::PERMISSION_EDIT)) { + $acl[] = [ + 'privilege' => '{DAV:}write-content', + 'principal' => $this->getOwner(), + 'protected' => true, + ]; + } - return $acl; + $this->acl = $acl; + return $this->acl; } public function setACL(array $acl) { diff --git a/lib/DAV/CalendarObject.php b/lib/DAV/CalendarObject.php index 4b8f6476cd..3ef37758d5 100644 --- a/lib/DAV/CalendarObject.php +++ b/lib/DAV/CalendarObject.php @@ -6,10 +6,15 @@ */ namespace OCA\Deck\DAV; +use OCA\Deck\BadRequestException; use OCA\Deck\Db\Card; use OCA\Deck\Db\Stack; +use OCA\Deck\StatusException; +use OCP\AppFramework\Db\DoesNotExistException; use Sabre\CalDAV\ICalendarObject; +use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; use Sabre\DAVACL\IACL; use Sabre\VObject\Component\VCalendar; @@ -43,7 +48,14 @@ public function getGroup() { } public function getACL() { - return $this->calendar->getACL(); + $acl = $this->calendar->getACL(); + if ($this->sourceItem instanceof Stack) { + return array_values(array_filter($acl, static function (array $entry): bool { + return $entry['privilege'] !== '{DAV:}write-content'; + })); + } + + return $acl; } public function setACL(array $acl) { @@ -55,7 +67,35 @@ public function getSupportedPrivilegeSet() { } public function put($data) { - throw new Forbidden('This calendar-object is read-only'); + if (!($this->sourceItem instanceof Card)) { + throw new Forbidden('This calendar-object is read-only'); + } + + // MultipleObjectsReturnedException is intentionally not caught: + // a primary-key lookup returning multiple rows is a data integrity bug + // and should surface as a 500 in the log. + try { + $this->sourceItem = $this->backend->updateCardFromCalendarObject($this->sourceItem, $this->readPutData($data)); + } catch (DoesNotExistException $e) { + throw new NotFound($e->getMessage(), 0, $e); + } catch (BadRequestException $e) { + throw new BadRequest($e->getMessage(), 0, $e); + } catch (StatusException $e) { + throw new Forbidden($e->getMessage(), 0, $e); + } + $this->calendarObject = $this->sourceItem->getCalendarObject(); + } + + private function readPutData($data): string { + if (is_resource($data)) { + $content = stream_get_contents($data); + if ($content === false) { + throw new BadRequest('Could not read calendar-object data'); + } + return $content; + } + + return (string)$data; } public function get() { @@ -77,7 +117,7 @@ public function getSize() { } public function delete() { - throw new Forbidden('This calendar-object is read-only'); + throw new Forbidden('Deleting tasks via CalDAV is not supported'); } public function getName() { diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php index 73146b0dda..dea0e034ea 100644 --- a/lib/DAV/DeckCalendarBackend.php +++ b/lib/DAV/DeckCalendarBackend.php @@ -12,11 +12,16 @@ use OCA\Deck\Db\Board; use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Db\Card; +use OCA\Deck\Model\OptionalNullableValue; use OCA\Deck\Service\BoardService; use OCA\Deck\Service\CardService; use OCA\Deck\Service\PermissionService; use OCA\Deck\Service\StackService; use Sabre\DAV\Exception\NotFound; +use Sabre\VObject\Component\VTodo; +use Sabre\VObject\InvalidDataException; +use Sabre\VObject\Reader; class DeckCalendarBackend { @@ -30,6 +35,8 @@ class DeckCalendarBackend { private $permissionService; /** @var BoardMapper */ private $boardMapper; + /** @var array> */ + private $permissionCache = []; public function __construct( BoardService $boardService, StackService $stackService, CardService $cardService, PermissionService $permissionService, @@ -55,8 +62,11 @@ public function getBoard(int $id): Board { } public function checkBoardPermission(int $id, int $permission): bool { - $permissions = $this->permissionService->getPermissions($id); - return isset($permissions[$permission]) ? $permissions[$permission] : false; + if (!isset($this->permissionCache[$id])) { + $this->permissionCache[$id] = $this->permissionService->getPermissions($id); + } + + return $this->permissionCache[$id][$permission] ?? false; } public function updateBoard(Board $board): bool { @@ -70,4 +80,78 @@ public function getChildren(int $id): array { $this->stackService->findCalendarEntries($id) ); } + + public function updateCardFromCalendarObject(Card $sourceCard, string $data): Card { + $todo = $this->extractTodo($data); + $card = $this->cardService->find($sourceCard->getId()); + + $title = trim((string)($todo->SUMMARY ?? '')); + if ($title === '') { + $title = $card->getTitle(); + } + + $description = isset($todo->DESCRIPTION) ? (string)$todo->DESCRIPTION : $card->getDescription(); + $dueDate = isset($todo->DUE) ? $todo->DUE->getDateTime()->format('c') : null; + $startDate = $card->getStartdate() ? $card->getStartdate()->format('c') : null; + + return $this->cardService->update( + id: $card->getId(), + title: $title, + stackId: $card->getStackId(), + type: $card->getType(), + owner: $card->getOwner() ?? '', + description: $description, + order: $card->getOrder(), + duedate: $dueDate, + deletedAt: $card->getDeletedAt(), + archived: $card->getArchived(), + done: $this->mapDoneFromTodo($todo, $card), + startdate: $startDate, + color: $card->getColor() + ); + } + + private function extractTodo(string $data): VTodo { + try { + $vObject = Reader::read($data); + } catch (\Exception $e) { + throw new InvalidDataException('Invalid calendar payload', 0, $e); + } + + $todos = $vObject->select('VTODO'); + if (count($todos) !== 1 || !($todos[0] instanceof VTodo)) { + throw new InvalidDataException('Calendar payload must contain exactly one VTODO'); + } + + return $todos[0]; + } + + private function mapDoneFromTodo(VTodo $todo, Card $card): OptionalNullableValue { + $done = $card->getDone(); + $percentComplete = isset($todo->{'PERCENT-COMPLETE'}) ? (int)((string)$todo->{'PERCENT-COMPLETE'}) : null; + $status = isset($todo->STATUS) ? strtoupper((string)$todo->STATUS) : null; + + // Deck only has a binary done state. IN-PROCESS maps to not done; + // statuses without a Deck equivalent, such as CANCELLED, keep the + // existing done value instead of inventing a new state. + if ($status === 'COMPLETED') { + $done = $this->computeDoneTimestamp($todo); + } elseif ($status === 'NEEDS-ACTION' || $status === 'IN-PROCESS') { + $done = null; + } elseif ($status === null) { + if (isset($todo->COMPLETED) || ($percentComplete !== null && $percentComplete >= 100)) { + $done = $this->computeDoneTimestamp($todo); + } elseif ($percentComplete !== null && $percentComplete === 0) { + $done = null; + } + } + + return new OptionalNullableValue($done); + } + + private function computeDoneTimestamp(VTodo $todo): \DateTime { + return isset($todo->COMPLETED) + ? \DateTime::createFromInterface($todo->COMPLETED->getDateTime()) + : new \DateTime(); + } } diff --git a/lib/Db/Card.php b/lib/Db/Card.php index ad8e7f67d9..ee1d4bcaaf 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -136,7 +136,7 @@ public function getCalendarObject(): VCalendar { $event->UID = 'deck-card-' . $this->getId(); if ($this->getDuedate()) { $creationDate = new DateTime(); - $creationDate->setTimestamp($this->createdAt); + $creationDate->setTimestamp($this->getCreatedAt()); $event->DTSTAMP = $creationDate; $event->DUE = new DateTime($this->getDuedate()->format('c'), new DateTimeZone('UTC')); } diff --git a/tests/integration/base-query-count.txt b/tests/integration/base-query-count.txt index ce8c604220..c488fdacfa 100644 --- a/tests/integration/base-query-count.txt +++ b/tests/integration/base-query-count.txt @@ -1 +1 @@ -93102 +96882 diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 8d7ec75f7d..211f21919c 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -35,18 +35,6 @@ - - - - - - - - - - - - diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 8ca73d9dd8..b5bd7492d2 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -725,3 +725,45 @@ namespace OCA\NotifyPush\Queue { namespace OCA\Text\Event { class LoadEditor extends \OCP\EventDispatcher\Event {} } + +namespace Sabre\VObject { + class InvalidDataException extends \Exception {} + + class Reader { + public static function read(string $data): Component\VCalendar {} + } +} + +namespace Sabre\VObject\Component { + class VCalendar { + /** @return array */ + public function select(string $name): array {} + public function createComponent(string $name): VTodo {} + public function add(mixed $value): void {} + public function serialize(): string {} + public function destroy(): void {} + } + + class VTodo { + public function __get(string $name): mixed {} + public function __set(string $name, mixed $value): void {} + public function __isset(string $name): bool {} + public function add(string $name, mixed $value): void {} + /** @var mixed */ + public $SUMMARY; + /** @var mixed */ + public $DESCRIPTION; + /** @var mixed */ + public $DUE; + /** @var mixed */ + public $COMPLETED; + /** @var mixed */ + public $STATUS; + } +} + +namespace Sabre\VObject\Property { + class DateTime { + public function getDateTime(): \DateTimeInterface {} + } +} diff --git a/tests/unit/DAV/CalendarObjectTest.php b/tests/unit/DAV/CalendarObjectTest.php new file mode 100644 index 0000000000..5ea7057d5e --- /dev/null +++ b/tests/unit/DAV/CalendarObjectTest.php @@ -0,0 +1,234 @@ +setId($id); + $card->setTitle('Card'); + $card->setDescription('Description'); + $card->setStackId(10); + $card->setType('plain'); + $card->setOwner('user1'); + $card->setOrder(1); + $card->setCreatedAt(100); + $card->setLastModified(200); + $card->setArchived(false); + $card->setDone(null); + return $card; + } + + private function createStack(): Stack { + $stack = new Stack(); + $stack->setId(10); + $stack->setTitle('Stack'); + $stack->setBoardId(123); + $stack->setLastModified(200); + return $stack; + } + + /** + * @return Calendar&MockObject + */ + private function createCalendarMock(): Calendar { + $calendar = $this->getMockBuilder(Calendar::class) + ->disableOriginalConstructor() + ->onlyMethods(['getACL', 'getOwner', 'getGroup']) + ->getMock(); + $calendar->method('getACL')->willReturn([ + [ + 'privilege' => '{DAV:}read', + 'principal' => 'principals/users/user1', + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}write-content', + 'principal' => 'principals/users/user1', + 'protected' => true, + ], + ]); + $calendar->method('getOwner')->willReturn('principals/users/user1'); + $calendar->method('getGroup')->willReturn([]); + return $calendar; + } + + public function testPutUpdatesCard(): void { + $calendar = $this->createCalendarMock(); + $sourceCard = $this->createCard(); + $updatedCard = $this->createCard(); + $updatedCard->setLastModified(300); + + $backend = $this->createMock(DeckCalendarBackend::class); + $backend->expects($this->once()) + ->method('updateCardFromCalendarObject') + ->with($sourceCard, "BEGIN:VCALENDAR\r\nEND:VCALENDAR") + ->willReturn($updatedCard); + + $object = new CalendarObject($calendar, 'card-1.ics', $backend, $sourceCard); + $object->put("BEGIN:VCALENDAR\r\nEND:VCALENDAR"); + + $this->assertSame(300, $object->getLastModified()); + } + + public function testPutReadsResourcePayload(): void { + $calendar = $this->createCalendarMock(); + $sourceCard = $this->createCard(); + $updatedCard = $this->createCard(); + $updatedCard->setLastModified(300); + $payload = "BEGIN:VCALENDAR\r\nEND:VCALENDAR"; + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $payload); + rewind($stream); + + $backend = $this->createMock(DeckCalendarBackend::class); + $backend->expects($this->once()) + ->method('updateCardFromCalendarObject') + ->with($sourceCard, $payload) + ->willReturn($updatedCard); + + $object = new CalendarObject($calendar, 'card-1.ics', $backend, $sourceCard); + $object->put($stream); + fclose($stream); + + $this->assertSame(300, $object->getLastModified()); + } + + public function testPutRefreshesSerializedObjectAndKeepsEtagStableForNextGet(): void { + $calendar = $this->createCalendarMock(); + $sourceCard = $this->createCard(); + $updatedCard = $this->createCard(); + $updatedCard->setTitle('Updated card'); + $updatedCard->setLastModified(300); + + $backend = $this->createMock(DeckCalendarBackend::class); + $backend->method('updateCardFromCalendarObject')->willReturn($updatedCard); + + $object = new CalendarObject($calendar, 'card-1.ics', $backend, $sourceCard); + $object->put("BEGIN:VCALENDAR\r\nEND:VCALENDAR"); + + $etag = $object->getETag(); + $serialized = $object->get(); + + $this->assertSame($etag, $object->getETag()); + $this->assertStringContainsString('SUMMARY:Updated card', $serialized); + } + + public function testStackPutIsForbidden(): void { + $object = new CalendarObject( + $this->createCalendarMock(), + 'stack-10.ics', + $this->createMock(DeckCalendarBackend::class), + $this->createStack() + ); + + $this->expectException(Forbidden::class); + $object->put("BEGIN:VCALENDAR\r\nEND:VCALENDAR"); + } + + public function testDeleteStaysForbiddenForCards(): void { + $object = new CalendarObject( + $this->createCalendarMock(), + 'card-1.ics', + $this->createMock(DeckCalendarBackend::class), + $this->createCard() + ); + + $this->expectException(Forbidden::class); + $object->delete(); + } + + public function testStackAclDoesNotExposeWriteContent(): void { + $object = new CalendarObject( + $this->createCalendarMock(), + 'stack-10.ics', + $this->createMock(DeckCalendarBackend::class), + $this->createStack() + ); + + $privileges = array_column($object->getACL(), 'privilege'); + $this->assertContains('{DAV:}read', $privileges); + $this->assertNotContains('{DAV:}write-content', $privileges); + } + + public function testPutMapsNoPermissionExceptionToForbidden(): void { + $backend = $this->createMock(DeckCalendarBackend::class); + $backend->method('updateCardFromCalendarObject') + ->willThrowException(new NoPermissionException('No edit permission')); + + $object = new CalendarObject( + $this->createCalendarMock(), + 'card-1.ics', + $backend, + $this->createCard() + ); + + $this->expectException(Forbidden::class); + $object->put("BEGIN:VCALENDAR\r\nEND:VCALENDAR"); + } + + public function testPutMapsStatusExceptionToForbidden(): void { + $backend = $this->createMock(DeckCalendarBackend::class); + $backend->method('updateCardFromCalendarObject') + ->willThrowException(new StatusException('Operation not allowed. This board is archived.')); + + $object = new CalendarObject( + $this->createCalendarMock(), + 'card-1.ics', + $backend, + $this->createCard() + ); + + $this->expectException(Forbidden::class); + $object->put("BEGIN:VCALENDAR\r\nEND:VCALENDAR"); + } + + public function testPutMapsBadRequestExceptionToBadRequest(): void { + $backend = $this->createMock(DeckCalendarBackend::class); + $backend->method('updateCardFromCalendarObject') + ->willThrowException(new BadRequestException('Invalid card data')); + + $object = new CalendarObject( + $this->createCalendarMock(), + 'card-1.ics', + $backend, + $this->createCard() + ); + + $this->expectException(BadRequest::class); + $object->put("BEGIN:VCALENDAR\r\nEND:VCALENDAR"); + } + + public function testPutMapsDoesNotExistExceptionToNotFound(): void { + $backend = $this->createMock(DeckCalendarBackend::class); + $backend->method('updateCardFromCalendarObject') + ->willThrowException(new DoesNotExistException('Card not found')); + + $object = new CalendarObject( + $this->createCalendarMock(), + 'card-1.ics', + $backend, + $this->createCard() + ); + + $this->expectException(NotFound::class); + $object->put("BEGIN:VCALENDAR\r\nEND:VCALENDAR"); + } +} diff --git a/tests/unit/DAV/CalendarTest.php b/tests/unit/DAV/CalendarTest.php new file mode 100644 index 0000000000..78f9b314fd --- /dev/null +++ b/tests/unit/DAV/CalendarTest.php @@ -0,0 +1,91 @@ +setId(123); + $board->setTitle('Board'); + $board->setColor('ff0000'); + $board->setLastModified(100); + return $board; + } + + public function testCalendarAclExposesWriteContentForEditors(): void { + $backend = $this->createMock(DeckCalendarBackend::class); + $backend->method('checkBoardPermission') + ->willReturnMap([ + [123, Acl::PERMISSION_EDIT, true], + ]); + + $calendar = new Calendar('principals/users/user1', 'board-123', $this->createBoard(), $backend); + $privileges = array_column($calendar->getACL(), 'privilege'); + + $this->assertContains('{DAV:}read', $privileges); + $this->assertContains('{DAV:}write-properties', $privileges); + $this->assertContains('{DAV:}write-content', $privileges); + $this->assertNotContains('{DAV:}write', $privileges); + $this->assertNotContains('{DAV:}bind', $privileges); + $this->assertNotContains('{DAV:}unbind', $privileges); + } + + public function testCalendarAclCachesPermissionCheck(): void { + $backend = $this->createMock(DeckCalendarBackend::class); + $backend->expects($this->once()) + ->method('checkBoardPermission') + ->with(123, Acl::PERMISSION_EDIT) + ->willReturn(true); + + $calendar = new Calendar('principals/users/user1', 'board-123', $this->createBoard(), $backend); + + $this->assertSame($calendar->getACL(), $calendar->getACL()); + } + + public function testCalendarIsNotSharedForDavSchedulePlugin(): void { + $calendar = new Calendar( + 'principals/users/user1', + 'board-123', + $this->createBoard(), + $this->createMock(DeckCalendarBackend::class) + ); + + $this->assertFalse($calendar->isShared()); + } + + public function testCalendarAclDoesNotExposeWriteContentForReadOnlyUsers(): void { + $backend = $this->createMock(DeckCalendarBackend::class); + $backend->method('checkBoardPermission') + ->willReturnMap([ + [123, Acl::PERMISSION_EDIT, false], + ]); + + $calendar = new Calendar('principals/users/user1', 'board-123', $this->createBoard(), $backend); + $privileges = array_column($calendar->getACL(), 'privilege'); + + $this->assertContains('{DAV:}read', $privileges); + $this->assertContains('{DAV:}write-properties', $privileges); + $this->assertNotContains('{DAV:}write-content', $privileges); + } + + public function testCreateFileStaysForbidden(): void { + $calendar = new Calendar( + 'principals/users/user1', + 'board-123', + $this->createBoard(), + $this->createMock(DeckCalendarBackend::class) + ); + + $this->expectException(Forbidden::class); + $calendar->createFile('client-generated.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR"); + } +} diff --git a/tests/unit/DAV/DeckCalendarBackendTest.php b/tests/unit/DAV/DeckCalendarBackendTest.php new file mode 100644 index 0000000000..57b084e44a --- /dev/null +++ b/tests/unit/DAV/DeckCalendarBackendTest.php @@ -0,0 +1,378 @@ +cardService = $this->createMock(CardService::class); + $this->permissionService = $this->createMock(PermissionService::class); + $this->backend = new DeckCalendarBackend( + $this->createMock(BoardService::class), + $this->createMock(StackService::class), + $this->cardService, + $this->permissionService, + $this->createMock(BoardMapper::class) + ); + } + + private function createCard(?\DateTimeInterface $done = null): Card { + $card = new Card(); + $card->setId(1); + $card->setTitle('Old title'); + $card->setDescription('Old description'); + $card->setStackId(10); + $card->setType('plain'); + $card->setOwner('user1'); + $card->setOrder(3); + $card->setDeletedAt(0); + $card->setArchived(false); + $card->setDone($done ? \DateTime::createFromInterface($done) : null); + $card->setStartdate(new \DateTime('2026-01-01T09:00:00+00:00')); + $card->setColor('ff0000'); + return $card; + } + + private function todoPayload(string $todo): string { + return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Nextcloud Deck Test//EN\r\n" . $todo . "\r\nEND:VCALENDAR\r\n"; + } + + public function testBoardPermissionsAreCachedPerBoard(): void { + $this->permissionService->expects($this->once()) + ->method('getPermissions') + ->with(123) + ->willReturn([ + 1 => true, + 2 => false, + ]); + + $this->assertTrue($this->backend->checkBoardPermission(123, 1)); + $this->assertFalse($this->backend->checkBoardPermission(123, 2)); + $this->assertFalse($this->backend->checkBoardPermission(123, 3)); + } + + public function testUpdateCardMapsSupportedFields(): void { + $sourceCard = $this->createCard(); + $currentCard = $this->createCard(); + $updatedCard = $this->createCard(); + $payload = $this->todoPayload( + "BEGIN:VTODO\r\n" + . "UID:deck-card-1\r\n" + . "SUMMARY:New title\r\n" + . "DESCRIPTION:New description\r\n" + . "DUE:20260507T100000Z\r\n" + . "STATUS:COMPLETED\r\n" + . 'END:VTODO' + ); + + $this->cardService->expects($this->once()) + ->method('find') + ->with(1) + ->willReturn($currentCard); + $this->cardService->expects($this->once()) + ->method('update') + ->with( + 1, + 'New title', + 10, + 'plain', + 'user1', + 'New description', + 3, + $this->callback(static fn (?string $value): bool => $value !== null && (new \DateTime($value))->getTimestamp() === 1778148000), + 0, + false, + $this->callback(static fn (OptionalNullableValue $value): bool => $value->getValue() instanceof \DateTimeInterface), + $this->callback(static fn (?string $value): bool => $value !== null && (new \DateTime($value))->getTimestamp() === 1767258000), + 'ff0000' + ) + ->willReturn($updatedCard); + + $this->assertSame($updatedCard, $this->backend->updateCardFromCalendarObject($sourceCard, $payload)); + } + + public function testPercentCompleteMiddleValueKeepsDoneState(): void { + $done = new \DateTime('2026-01-02T09:00:00+00:00'); + $sourceCard = $this->createCard($done); + $currentCard = $this->createCard($done); + $payload = $this->todoPayload( + "BEGIN:VTODO\r\n" + . "UID:deck-card-1\r\n" + . "SUMMARY:Old title\r\n" + . "PERCENT-COMPLETE:50\r\n" + . 'END:VTODO' + ); + + $this->cardService->method('find')->willReturn($currentCard); + $this->cardService->expects($this->once()) + ->method('update') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->callback(static fn (OptionalNullableValue $value): bool => $value->getValue() instanceof \DateTimeInterface + && $value->getValue()->getTimestamp() === $done->getTimestamp()), + $this->anything(), + $this->anything() + ) + ->willReturn($currentCard); + + $this->backend->updateCardFromCalendarObject($sourceCard, $payload); + } + + public function testNeedsActionStatusWinsOverStaleCompletedProperty(): void { + $done = new \DateTime('2026-01-02T09:00:00+00:00'); + $sourceCard = $this->createCard($done); + $currentCard = $this->createCard($done); + $payload = $this->todoPayload( + "BEGIN:VTODO\r\n" + . "UID:deck-card-1\r\n" + . "SUMMARY:Old title\r\n" + . "STATUS:NEEDS-ACTION\r\n" + . "COMPLETED:20260102T090000Z\r\n" + . 'END:VTODO' + ); + + $this->cardService->method('find')->willReturn($currentCard); + $this->cardService->expects($this->once()) + ->method('update') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->callback(static fn (OptionalNullableValue $value): bool => $value->getValue() === null), + $this->anything(), + $this->anything() + ) + ->willReturn($currentCard); + + $this->backend->updateCardFromCalendarObject($sourceCard, $payload); + } + + public function testCancelledStatusKeepsDoneState(): void { + $done = new \DateTime('2026-01-02T09:00:00+00:00'); + $sourceCard = $this->createCard($done); + $currentCard = $this->createCard($done); + $payload = $this->todoPayload( + "BEGIN:VTODO\r\n" + . "UID:deck-card-1\r\n" + . "SUMMARY:Old title\r\n" + . "STATUS:CANCELLED\r\n" + . "PERCENT-COMPLETE:0\r\n" + . 'END:VTODO' + ); + + $this->cardService->method('find')->willReturn($currentCard); + $this->cardService->expects($this->once()) + ->method('update') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->callback(static fn (OptionalNullableValue $value): bool => $value->getValue() instanceof \DateTimeInterface + && $value->getValue()->getTimestamp() === $done->getTimestamp()), + $this->anything(), + $this->anything() + ) + ->willReturn($currentCard); + + $this->backend->updateCardFromCalendarObject($sourceCard, $payload); + } + + public function testEmptyDescriptionClearsDescription(): void { + $sourceCard = $this->createCard(); + $currentCard = $this->createCard(); + $payload = $this->todoPayload( + "BEGIN:VTODO\r\n" + . "UID:deck-card-1\r\n" + . "SUMMARY:Old title\r\n" + . "DESCRIPTION:\r\n" + . 'END:VTODO' + ); + + $this->cardService->method('find')->willReturn($currentCard); + $this->cardService->expects($this->once()) + ->method('update') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + '', + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn($currentCard); + + $this->backend->updateCardFromCalendarObject($sourceCard, $payload); + } + + public function testMissingDescriptionKeepsCurrentDescription(): void { + $sourceCard = $this->createCard(); + $currentCard = $this->createCard(); + $payload = $this->todoPayload( + "BEGIN:VTODO\r\n" + . "UID:deck-card-1\r\n" + . "SUMMARY:Old title\r\n" + . 'END:VTODO' + ); + + $this->cardService->method('find')->willReturn($currentCard); + $this->cardService->expects($this->once()) + ->method('update') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + 'Old description', + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn($currentCard); + + $this->backend->updateCardFromCalendarObject($sourceCard, $payload); + } + + public function testEmptySummaryFallsBackToCurrentTitle(): void { + $sourceCard = $this->createCard(); + $currentCard = $this->createCard(); + $payload = $this->todoPayload( + "BEGIN:VTODO\r\n" + . "UID:deck-card-1\r\n" + . "SUMMARY:\r\n" + . "STATUS:NEEDS-ACTION\r\n" + . 'END:VTODO' + ); + + $this->cardService->method('find')->willReturn($currentCard); + $this->cardService->expects($this->once()) + ->method('update') + ->with( + $this->anything(), + 'Old title', + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->callback(static fn (OptionalNullableValue $value): bool => $value->getValue() === null), + $this->anything(), + $this->anything() + ) + ->willReturn($currentCard); + + $this->backend->updateCardFromCalendarObject($sourceCard, $payload); + } + + public function testPayloadMustContainExactlyOneTodo(): void { + $this->expectException(InvalidDataException::class); + $this->backend->updateCardFromCalendarObject($this->createCard(), "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"); + } + + public function testInvalidCalendarPayloadThrowsInvalidDataException(): void { + $this->expectException(InvalidDataException::class); + $this->backend->updateCardFromCalendarObject($this->createCard(), 'not an ics payload'); + } + + public function testDtStartFromPayloadIsIgnored(): void { + $sourceCard = $this->createCard(); + $currentCard = new Card(); + $currentCard->setId(1); + $currentCard->setTitle('Old title'); + $currentCard->setDescription('Old description'); + $currentCard->setStackId(10); + $currentCard->setType('plain'); + $currentCard->setOwner('user1'); + $currentCard->setOrder(3); + $currentCard->setDeletedAt(0); + $currentCard->setArchived(false); + $currentCard->setDone(null); + $currentCard->setStartdate(null); + $currentCard->setColor(null); + + $payload = $this->todoPayload( + "BEGIN:VTODO\r\n" + . "UID:deck-card-1\r\n" + . "SUMMARY:Title\r\n" + . "DTSTART:20260506T100000Z\r\n" + . 'END:VTODO' + ); + + $this->cardService->method('find')->willReturn($currentCard); + $this->cardService->expects($this->once()) + ->method('update') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->isNull(), + $this->anything() + ) + ->willReturn($currentCard); + + $this->backend->updateCardFromCalendarObject($sourceCard, $payload); + } +}