write tests

This commit is contained in:
stefan.tollkuehn committed 2026-10-08 14:09:12 +02:00
1 parent 9abacef435
commit 2d141ee46c
5 files changed
+3457

No files matched your search

+712
View File
@@ -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']
);
}
}