write tests
This commit is contained in:
1 parent
9abacef435
commit
2d141ee46c
5 files changed
+3457
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
/vendor/
|
||||||
|
.phpunit.result.cache
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "bnvb-ingenieure/atocal",
|
||||||
|
"description": "Create events in the user's Nextcloud Calendar",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"nextcloud/ocp": "^34 || ^35"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11.0",
|
||||||
|
"sabre/vobject": "^4.5"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"OCA\\AToCal\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"OCA\\AToCal\\Tests\\": "tests/",
|
||||||
|
"OCP\\": "vendor/nextcloud/ocp/OCP/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2708
File diff suppressed because it is too large.
Load diff
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="unit">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,712 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\AToCal\Tests\Unit\Controller;
|
||||||
|
|
||||||
|
use OCA\AToCal\Controller\CalendarController;
|
||||||
|
use OCP\App\IAppManager;
|
||||||
|
use OCP\AppFramework\Http\RedirectResponse;
|
||||||
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
|
use OCP\Calendar\ICalendar;
|
||||||
|
use OCP\Calendar\ICalendarEventBuilder;
|
||||||
|
use OCP\Calendar\ICreateFromString;
|
||||||
|
use OCP\Calendar\IManager;
|
||||||
|
use OCP\Constants;
|
||||||
|
use OCP\IDateTimeZone;
|
||||||
|
use OCP\IL10N;
|
||||||
|
use OCP\IRequest;
|
||||||
|
use OCP\IURLGenerator;
|
||||||
|
use OCP\IUser;
|
||||||
|
use OCP\IUserSession;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
class CalendarControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private IManager&MockObject $calendarManager;
|
||||||
|
private IAppManager&MockObject $appManager;
|
||||||
|
private IUserSession&MockObject $userSession;
|
||||||
|
private IURLGenerator&MockObject $urlGenerator;
|
||||||
|
private LoggerInterface&MockObject $logger;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->calendarManager = $this->createMock(IManager::class);
|
||||||
|
$this->appManager = $this->createMock(IAppManager::class);
|
||||||
|
$this->userSession = $this->createMock(IUserSession::class);
|
||||||
|
$this->urlGenerator = $this->createMock(IURLGenerator::class);
|
||||||
|
$this->logger = $this->createMock(LoggerInterface::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function newController(): CalendarController
|
||||||
|
{
|
||||||
|
$dateTimeZone = $this->createMock(IDateTimeZone::class);
|
||||||
|
$dateTimeZone->method('getTimeZone')->willReturn(new \DateTimeZone('UTC'));
|
||||||
|
|
||||||
|
$l10n = $this->createMock(IL10N::class);
|
||||||
|
$l10n->method('t')->willReturnCallback(
|
||||||
|
static fn (string $text) => $text
|
||||||
|
);
|
||||||
|
|
||||||
|
return new CalendarController(
|
||||||
|
'atocal',
|
||||||
|
$this->createMock(IRequest::class),
|
||||||
|
$this->calendarManager,
|
||||||
|
$this->appManager,
|
||||||
|
$this->userSession,
|
||||||
|
$this->urlGenerator,
|
||||||
|
$dateTimeZone,
|
||||||
|
$l10n,
|
||||||
|
$this->logger
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invoke(CalendarController $c, string $method, array $args = []): mixed
|
||||||
|
{
|
||||||
|
$m = new \ReflectionMethod(CalendarController::class, $method);
|
||||||
|
|
||||||
|
return $m->invokeArgs($c, $args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mockUser(string $uid): IUser&MockObject
|
||||||
|
{
|
||||||
|
$user = $this->createMock(IUser::class);
|
||||||
|
$user->method('getUID')->willReturn($uid);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mockCalendar(
|
||||||
|
string $key,
|
||||||
|
string $uri,
|
||||||
|
string $displayName,
|
||||||
|
int $permissions
|
||||||
|
): ICreateFromString&MockObject {
|
||||||
|
$calendar = $this->createMock(ICreateFromString::class);
|
||||||
|
$calendar->method('getKey')->willReturn($key);
|
||||||
|
$calendar->method('getUri')->willReturn($uri);
|
||||||
|
$calendar->method('getDisplayName')->willReturn($displayName);
|
||||||
|
$calendar->method('getPermissions')->willReturn($permissions);
|
||||||
|
|
||||||
|
return $calendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* assertClean
|
||||||
|
*/
|
||||||
|
|
||||||
|
public static function provideCleanCases(): iterable
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'plain text' => ['Weekly sync', false, true],
|
||||||
|
'description with newline' => ["line one\nline two", true, true],
|
||||||
|
'newline without allowance' => ["line one\nline two", false, false],
|
||||||
|
'NUL byte' => ["bad\x00title", false, false],
|
||||||
|
'backspace' => ["bad\x08title", false, false],
|
||||||
|
'delete char' => ["bad\x7Ftitle", false, false],
|
||||||
|
'bidi override LRE' => ["\u{202A}evil", false, false],
|
||||||
|
'bidi override RLO' => ["\u{202E}evil", false, false],
|
||||||
|
'bidi isolate LRI' => ["\u{2066}evil", false, false],
|
||||||
|
'bidi isolate PDI' => ["\u{2069}evil", false, false],
|
||||||
|
'line separator' => ["a\u{2028}b", false, false],
|
||||||
|
'paragraph separator' => ["a\u{2029}b", false, false],
|
||||||
|
'next line' => ["a\u{0085}b", false, false],
|
||||||
|
'zero-width joiner allowed' => ["\u{1F469}\u{200D}\u{1F4BB} dev", false, true],
|
||||||
|
'combining accent allowed' => ["caf\u{00E9}", false, true],
|
||||||
|
'invalid utf-8' => ["bad\xC3\x28utf", false, false],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideCleanCases')]
|
||||||
|
public function testAssertClean(string $value, bool $allowNewlines, bool $expectedClean): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
if ($expectedClean) {
|
||||||
|
$this->invoke($c, 'assertClean', [$value, 'bad', $allowNewlines]);
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->invoke($c, 'assertClean', [$value, 'bad', $allowNewlines]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* assertHttpsUrl
|
||||||
|
*/
|
||||||
|
|
||||||
|
public static function provideUrlCases(): iterable
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'valid https' => ['https://example.com/meeting', true],
|
||||||
|
'valid with path query' => ['https://example.com/p?a=1#frag', true],
|
||||||
|
'space inside' => ['https://exa mple.com/', false],
|
||||||
|
'backslash user confusion' => ['https://example.com\\@evil.com/', false],
|
||||||
|
'backslash in path' => ['https://example.com/path\\', false],
|
||||||
|
'non-ascii host' => ['https://еxample.com/', false],
|
||||||
|
'userinfo user' => ['https://user@example.com/', false],
|
||||||
|
'userinfo pass only' => ['https://:pass@example.com/', false],
|
||||||
|
'userinfo user and pass' => ['https://u:p@example.com/', false],
|
||||||
|
'http scheme' => ['http://example.com/', false],
|
||||||
|
'ftp scheme' => ['ftp://example.com/', false],
|
||||||
|
'no host' => ['https://', false],
|
||||||
|
'garbage' => ['not a url', false],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideUrlCases')]
|
||||||
|
public function testAssertHttpsUrl(string $value, bool $expectedValid): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
if ($expectedValid) {
|
||||||
|
$this->invoke($c, 'assertHttpsUrl', [$value, 'bad url']);
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->invoke($c, 'assertHttpsUrl', [$value, 'bad url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* assertBoundedLength
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function testAssertBoundedLength(): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$this->invoke($c, 'assertBoundedLength', ['12345', 5, 'too long']);
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->invoke($c, 'assertBoundedLength', ['123456', 5, 'too long']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* parseLocalDateTime
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function testParseLocalDateTime(): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$date = $this->invoke($c, 'parseLocalDateTime', ['2026-10-07T14:15']);
|
||||||
|
$this->assertInstanceOf(\DateTimeImmutable::class, $date);
|
||||||
|
$this->assertSame('2026-10-07 14:15:00 UTC', $date->format('Y-m-d H:i:s T'));
|
||||||
|
|
||||||
|
$date = $this->invoke($c, 'parseLocalDateTime', ['2026-10-07T14:15:30']);
|
||||||
|
$this->assertSame('2026-10-07 14:15:30 UTC', $date->format('Y-m-d H:i:s T'));
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->invoke($c, 'parseLocalDateTime', ['tomorrow']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* validateEventData
|
||||||
|
*/
|
||||||
|
|
||||||
|
private function validInput(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'summary' => ' Weekly sync ',
|
||||||
|
'description' => ' Agenda attached ',
|
||||||
|
'start' => '2026-10-07T14:15',
|
||||||
|
'end' => '2026-10-07T15:15',
|
||||||
|
'organizer' => 'boss@example.com',
|
||||||
|
'location' => 'https://meet.example.com/room',
|
||||||
|
'url' => 'https://meet.example.com/room',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateEventDataNormalizes(): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$data = $this->invoke($c, 'validateEventData', array_values($this->validInput()));
|
||||||
|
|
||||||
|
$this->assertSame('Weekly sync', $data['summary']);
|
||||||
|
$this->assertSame('Agenda attached', $data['description']);
|
||||||
|
$this->assertSame('boss@example.com', $data['organizer']);
|
||||||
|
$this->assertInstanceOf(\DateTimeImmutable::class, $data['start']);
|
||||||
|
$this->assertInstanceOf(\DateTimeImmutable::class, $data['end']);
|
||||||
|
$this->assertSame('https://meet.example.com/room', $data['location']);
|
||||||
|
$this->assertSame('https://meet.example.com/room', $data['url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideValidationFailures(): iterable
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'missing title' => [
|
||||||
|
['summary' => ''],
|
||||||
|
'Meeting title is required.',
|
||||||
|
],
|
||||||
|
'title too long' => [
|
||||||
|
['summary' => str_repeat('a', 256)],
|
||||||
|
'Meeting title is too long.',
|
||||||
|
],
|
||||||
|
'title control char' => [
|
||||||
|
['summary' => "bad\x00title"],
|
||||||
|
'Meeting title contains invalid characters.',
|
||||||
|
],
|
||||||
|
'title bidi override' => [
|
||||||
|
['summary' => "\u{202E}evil title"],
|
||||||
|
'Meeting title contains invalid characters.',
|
||||||
|
],
|
||||||
|
'description too long' => [
|
||||||
|
['description' => str_repeat('a', 10001)],
|
||||||
|
'Meeting description is too long.',
|
||||||
|
],
|
||||||
|
'description control char' => [
|
||||||
|
['description' => "bad\x01desc"],
|
||||||
|
'Meeting description contains invalid characters.',
|
||||||
|
],
|
||||||
|
'end before start' => [
|
||||||
|
['start' => '2026-10-07T15:15', 'end' => '2026-10-07T14:15'],
|
||||||
|
'End time must be after start time.',
|
||||||
|
],
|
||||||
|
'bad date format' => [
|
||||||
|
['start' => 'tomorrow', 'end' => '2026-10-07T14:15'],
|
||||||
|
'Invalid date/time.',
|
||||||
|
],
|
||||||
|
'location not https' => [
|
||||||
|
['location' => 'http://meet.example.com/'],
|
||||||
|
'Location must be an HTTPS URL.',
|
||||||
|
],
|
||||||
|
'location with userinfo' => [
|
||||||
|
['location' => 'https://user@meet.example.com/'],
|
||||||
|
'Location must be an HTTPS URL.',
|
||||||
|
],
|
||||||
|
'location too long' => [
|
||||||
|
['location' => 'https://example.com/' . str_repeat('a', 2000)],
|
||||||
|
'Location is too long.',
|
||||||
|
],
|
||||||
|
'url not https' => [
|
||||||
|
['url' => 'http://meet.example.com/'],
|
||||||
|
'Meeting URL must be an HTTPS URL.',
|
||||||
|
],
|
||||||
|
'url control char' => [
|
||||||
|
['url' => "https://example.com/\x00/room"],
|
||||||
|
'Meeting URL contains invalid characters.',
|
||||||
|
],
|
||||||
|
'organizer no email' => [
|
||||||
|
['organizer' => 'not-an-email'],
|
||||||
|
'Invalid organizer email address.',
|
||||||
|
],
|
||||||
|
'organizer too long' => [
|
||||||
|
['organizer' => str_repeat('a', 250) . '@example.com'],
|
||||||
|
'Organizer email address is too long.',
|
||||||
|
],
|
||||||
|
'organizer control char' => [
|
||||||
|
['organizer' => "boss\x00@example.com"],
|
||||||
|
'Organizer contains invalid characters.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideValidationFailures')]
|
||||||
|
public function testValidateEventDataRejects(array $overrides, string $message): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$input = array_merge($this->validInput(), $overrides);
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage($message);
|
||||||
|
|
||||||
|
$this->invoke($c, 'validateEventData', array_values($input));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* mergeUrlIntoDescription
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function testMergeUrlIntoDescription(): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$urlEqualsLocation = [
|
||||||
|
'url' => 'https://example.com/room',
|
||||||
|
'location' => 'https://example.com/room',
|
||||||
|
'description' => 'Agenda',
|
||||||
|
];
|
||||||
|
$this->assertSame(
|
||||||
|
'Agenda',
|
||||||
|
$this->invoke($c, 'mergeUrlIntoDescription', [$urlEqualsLocation])
|
||||||
|
);
|
||||||
|
|
||||||
|
$emptyDescription = [
|
||||||
|
'url' => 'https://example.com/room',
|
||||||
|
'location' => '',
|
||||||
|
'description' => '',
|
||||||
|
];
|
||||||
|
$this->assertSame(
|
||||||
|
'https://example.com/room',
|
||||||
|
$this->invoke($c, 'mergeUrlIntoDescription', [$emptyDescription])
|
||||||
|
);
|
||||||
|
|
||||||
|
$both = [
|
||||||
|
'url' => 'https://example.com/room',
|
||||||
|
'location' => 'https://meet.example.com/other',
|
||||||
|
'description' => 'Agenda',
|
||||||
|
];
|
||||||
|
$this->assertSame(
|
||||||
|
"Agenda\n\nhttps://example.com/room",
|
||||||
|
$this->invoke($c, 'mergeUrlIntoDescription', [$both])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* calendar form value, selection and default
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function testCalendarValue(): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE);
|
||||||
|
|
||||||
|
$this->assertSame('5::personal', $this->invoke($c, 'calendarValue', [$calendar]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSelectCalendarResolvesKeyCollision(): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$personal = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE);
|
||||||
|
$shared = $this->mockCalendar('5', 'shared-by-bob', 'Shared', Constants::PERMISSION_CREATE);
|
||||||
|
|
||||||
|
$calendars = [$personal, $shared];
|
||||||
|
|
||||||
|
$this->assertSame($personal, $this->invoke($c, 'selectCalendar', [$calendars, '5::personal']));
|
||||||
|
$this->assertSame($shared, $this->invoke($c, 'selectCalendar', [$calendars, '5::shared-by-bob']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideNoMatchValues(): iterable
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'no separator' => ['5'],
|
||||||
|
'wrong uri' => ['5::nope'],
|
||||||
|
'wrong key' => ['9::personal'],
|
||||||
|
'empty' => [''],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideNoMatchValues')]
|
||||||
|
public function testSelectCalendarNoMatch(string $value): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE);
|
||||||
|
|
||||||
|
$this->assertNull($this->invoke($c, 'selectCalendar', [[$calendar], $value]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultCalendarId(): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$work = $this->mockCalendar('1', 'work', 'Work', Constants::PERMISSION_CREATE);
|
||||||
|
$personal = $this->mockCalendar('2', 'personal', 'Personal', Constants::PERMISSION_CREATE);
|
||||||
|
|
||||||
|
$this->assertSame('2::personal', $this->invoke($c, 'defaultCalendarId', [[$work, $personal]]));
|
||||||
|
$this->assertSame('1::work', $this->invoke($c, 'defaultCalendarId', [[$work]]));
|
||||||
|
$this->assertSame('', $this->invoke($c, 'defaultCalendarId', [[]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* findWritableCalendars
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function testFindWritableCalendars(): void
|
||||||
|
{
|
||||||
|
$this->calendarManager->method('getCalendarsForPrincipal')
|
||||||
|
->with('principals/users/alice')
|
||||||
|
->willReturn([
|
||||||
|
$this->mockCalendar('1', 'work', 'Work', Constants::PERMISSION_CREATE),
|
||||||
|
$this->mockCalendar('2', 'shared', 'Shared', Constants::PERMISSION_READ),
|
||||||
|
$this->createMock(ICalendar::class),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$writable = $this->invoke($c, 'findWritableCalendars', ['alice']);
|
||||||
|
|
||||||
|
$this->assertCount(1, $writable);
|
||||||
|
$this->assertSame('1', $writable[0]->getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* createEvent: VALARM injection
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function testCreateEventInjectsValarm(): void
|
||||||
|
{
|
||||||
|
$builder = $this->createMock(ICalendarEventBuilder::class);
|
||||||
|
$builder->method('toIcs')->willReturn(
|
||||||
|
"BEGIN:VCALENDAR\r\n" .
|
||||||
|
"VERSION:2.0\r\n" .
|
||||||
|
"PRODID:-//Nextcloud//AToCal//EN\r\n" .
|
||||||
|
"BEGIN:VEVENT\r\n" .
|
||||||
|
"UID:test-uid-123\r\n" .
|
||||||
|
"SUMMARY:Weekly sync\r\n" .
|
||||||
|
"END:VEVENT\r\n" .
|
||||||
|
"END:VCALENDAR\r\n"
|
||||||
|
);
|
||||||
|
$this->calendarManager->method('createEventBuilder')->willReturn($builder);
|
||||||
|
|
||||||
|
$calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE);
|
||||||
|
$calendar->expects($this->once())->method('createFromString')
|
||||||
|
->willReturnCallback(function (string $name, string $data): bool {
|
||||||
|
$this->assertSame('test-uid-123.ics', $name);
|
||||||
|
$this->assertStringContainsString('BEGIN:VALARM', $data);
|
||||||
|
$this->assertStringContainsString('TRIGGER:-PT15M', $data);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'summary' => 'Weekly sync',
|
||||||
|
'description' => '',
|
||||||
|
'start' => new \DateTimeImmutable('2026-10-07T14:15:00 UTC'),
|
||||||
|
'end' => new \DateTimeImmutable('2026-10-07T15:15:00 UTC'),
|
||||||
|
'organizer' => '',
|
||||||
|
'location' => '',
|
||||||
|
'url' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->invoke($c, 'createEvent', [$calendar, $data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* create(): happy path redirects to the Calendar app
|
||||||
|
*/
|
||||||
|
|
||||||
|
private function setUpWritableCalendars(): void
|
||||||
|
{
|
||||||
|
$this->calendarManager->method('getCalendarsForPrincipal')
|
||||||
|
->willReturn([
|
||||||
|
$this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The event builder ICS is parsed by VObject before it
|
||||||
|
* reaches createFromString(), so the create() tests need
|
||||||
|
* a valid skeleton ICS.
|
||||||
|
*/
|
||||||
|
private function setUpEventBuilder(): void
|
||||||
|
{
|
||||||
|
$builder = $this->createMock(ICalendarEventBuilder::class);
|
||||||
|
$builder->method('toIcs')->willReturn(
|
||||||
|
"BEGIN:VCALENDAR\r\n" .
|
||||||
|
"VERSION:2.0\r\n" .
|
||||||
|
"PRODID:-//Nextcloud//AToCal//EN\r\n" .
|
||||||
|
"BEGIN:VEVENT\r\n" .
|
||||||
|
"UID:test-uid-123\r\n" .
|
||||||
|
"SUMMARY:Weekly sync\r\n" .
|
||||||
|
"END:VEVENT\r\n" .
|
||||||
|
"END:VCALENDAR\r\n"
|
||||||
|
);
|
||||||
|
$this->calendarManager->method('createEventBuilder')->willReturn($builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validCreateArgs(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Weekly sync',
|
||||||
|
'Agenda',
|
||||||
|
'2026-10-07T14:15',
|
||||||
|
'2026-10-07T15:15',
|
||||||
|
'boss@example.com',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'5::personal',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRedirectsToCalendarApp(): void
|
||||||
|
{
|
||||||
|
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
|
||||||
|
$this->setUpWritableCalendars();
|
||||||
|
$this->setUpEventBuilder();
|
||||||
|
$this->appManager->method('isEnabledForUser')->willReturn(true);
|
||||||
|
$this->urlGenerator->method('linkToRoute')->willReturn('/apps/calendar/');
|
||||||
|
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$response = $c->create(...$this->validCreateArgs());
|
||||||
|
|
||||||
|
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||||
|
$this->assertSame('/apps/calendar/', $response->getRedirectURL());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateFallsBackWithoutCalendarApp(): void
|
||||||
|
{
|
||||||
|
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
|
||||||
|
$this->setUpWritableCalendars();
|
||||||
|
$this->setUpEventBuilder();
|
||||||
|
$this->appManager->method('isEnabledForUser')->willReturn(false);
|
||||||
|
$this->urlGenerator->method('getAbsoluteURL')->willReturn('https://cloud.example.com/');
|
||||||
|
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$response = $c->create(...$this->validCreateArgs());
|
||||||
|
|
||||||
|
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||||
|
$this->assertSame('https://cloud.example.com/', $response->getRedirectURL());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateUnwritableSelectionShowsError(): void
|
||||||
|
{
|
||||||
|
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
|
||||||
|
$this->setUpWritableCalendars();
|
||||||
|
|
||||||
|
$args = $this->validCreateArgs();
|
||||||
|
$args[7] = '9::nope';
|
||||||
|
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$response = $c->create(...$args);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(TemplateResponse::class, $response);
|
||||||
|
$this->assertSame('confirm', $response->getTemplateName());
|
||||||
|
$this->assertSame(
|
||||||
|
'The selected calendar is no longer writable. Pick another one and try again.',
|
||||||
|
$response->getParams()['error']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateNoWritableCalendars(): void
|
||||||
|
{
|
||||||
|
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
|
||||||
|
$this->calendarManager->method('getCalendarsForPrincipal')->willReturn([]);
|
||||||
|
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$response = $c->create(...$this->validCreateArgs());
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.',
|
||||||
|
$response->getParams()['error']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateLogsBackendFailure(): void
|
||||||
|
{
|
||||||
|
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
|
||||||
|
$this->setUpEventBuilder();
|
||||||
|
|
||||||
|
$calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE);
|
||||||
|
$calendar->method('createFromString')
|
||||||
|
->willThrowException(new \RuntimeException('backend down'));
|
||||||
|
$this->calendarManager->method('getCalendarsForPrincipal')
|
||||||
|
->willReturn([$calendar]);
|
||||||
|
|
||||||
|
$this->logger->expects($this->once())->method('error')
|
||||||
|
->with(
|
||||||
|
'Failed to create calendar event: backend down',
|
||||||
|
['exception' => new \RuntimeException('backend down')]
|
||||||
|
);
|
||||||
|
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$response = $c->create(...$this->validCreateArgs());
|
||||||
|
|
||||||
|
$this->assertInstanceOf(TemplateResponse::class, $response);
|
||||||
|
$this->assertSame(
|
||||||
|
'The event could not be created. Please try again.',
|
||||||
|
$response->getParams()['error']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithoutUserThrows(): void
|
||||||
|
{
|
||||||
|
$this->userSession->method('getUser')->willReturn(null);
|
||||||
|
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('No authenticated Nextcloud user.');
|
||||||
|
|
||||||
|
$c->create(...$this->validCreateArgs());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateInvalidInputRendersErrorPage(): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$args = $this->validCreateArgs();
|
||||||
|
$args[0] = '';
|
||||||
|
|
||||||
|
$response = $c->create(...$args);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(TemplateResponse::class, $response);
|
||||||
|
$this->assertSame('error', $response->getTemplateName());
|
||||||
|
$this->assertSame('core', $response->getApp());
|
||||||
|
$this->assertSame(
|
||||||
|
'Meeting title is required.',
|
||||||
|
$response->getParams()['errors'][0]['error']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* confirm()
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function testConfirmRendersTemplate(): void
|
||||||
|
{
|
||||||
|
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
|
||||||
|
$this->setUpWritableCalendars();
|
||||||
|
$this->urlGenerator->method('getAbsoluteURL')->willReturn('https://cloud.example.com/');
|
||||||
|
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$response = $c->confirm(
|
||||||
|
'Weekly sync',
|
||||||
|
'Agenda',
|
||||||
|
'2026-10-07T14:15',
|
||||||
|
'2026-10-07T15:15',
|
||||||
|
'boss@example.com',
|
||||||
|
'https://meet.example.com/room',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(TemplateResponse::class, $response);
|
||||||
|
$this->assertSame('confirm', $response->getTemplateName());
|
||||||
|
|
||||||
|
$params = $response->getParams();
|
||||||
|
$this->assertSame('Weekly sync', $params['summary']);
|
||||||
|
$this->assertSame('2026-10-07T14:15', $params['start']);
|
||||||
|
$this->assertSame('', $params['error']);
|
||||||
|
$this->assertSame('5::personal', $params['calendars'][0]['value']);
|
||||||
|
$this->assertSame('5::personal', $params['calendarId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConfirmInvalidInputRendersErrorPage(): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$response = $c->confirm(
|
||||||
|
'Weekly sync',
|
||||||
|
'',
|
||||||
|
'2026-10-07T14:15',
|
||||||
|
'2026-10-07T13:15'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('error', $response->getTemplateName());
|
||||||
|
$this->assertSame(
|
||||||
|
'End time must be after start time.',
|
||||||
|
$response->getParams()['errors'][0]['error']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user