Files
atocal/tests/Unit/CalendarControllerTest.php
T
stefan.tollkuehn 97cea7e90b Make calendar owner the event organizer
The ICS organizer was taken from the external link's
organizer parameter. The CalDAV backend only schedules
invitations when the organizer matches the calendar
owner, so invites never went out under that value. The
event now names the authenticated user's email address;
the link organizer remains the fallback for users
without a profile email and stays visible on the
confirm page as meeting information.
2026-10-22 19:23:25 +02:00

1049 lines
35 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace OCA\AToCal\Tests\Unit\Controller;
use OCA\AToCal\Controller\CalendarController;
use OCP\App\IAppManager;
use OCP\AppFramework\Http\DataResponse;
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\Contacts\IManager as IContactsManager;
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;
require_once __DIR__ . '/OCUtilStub.php';
class CalendarControllerTest extends TestCase
{
private IManager&MockObject $calendarManager;
private IContactsManager&MockObject $contactsManager;
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->contactsManager = $this->createMock(IContactsManager::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, array $params = []): string => $params === []
? $text
: vsprintf($text, $params)
);
return new CalendarController(
'atocal',
$this->createMock(IRequest::class),
$this->calendarManager,
$this->contactsManager,
$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' => '',
'organizerEmail' => '',
'location' => '',
'url' => '',
'attendees' => [],
];
$this->invoke($c, 'createEvent', [$calendar, $data]);
}
/*
* createEvent: ATTENDEE injection
*/
public function testCreateEventInvitesAttendees(): 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->assertStringContainsString(
'ATTENDEE;CN=Alice Example:mailto:alice@example.com',
$data
);
$this->assertStringContainsString(
'ATTENDEE:mailto:bob@example.com',
$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' => '',
'organizerEmail' => '',
'location' => '',
'url' => '',
'attendees' => [
['email' => 'alice@example.com', 'name' => 'Alice Example'],
['email' => 'bob@example.com', 'name' => ''],
],
];
$this->invoke($c, 'createEvent', [$calendar, $data]);
}
/*
* createEvent: the calendar owner is the ICS organizer
*/
private function skeletonIcs(): string
{
return
"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";
}
private function eventBuilderWith(\PHPUnit\Framework\MockObject\MockObject|ICalendarEventBuilder $builder): void
{
$builder->method('toIcs')->willReturn($this->skeletonIcs());
$this->calendarManager->method('createEventBuilder')->willReturn($builder);
}
public function testCreateEventPrefersUserEmailAsOrganizer(): void
{
$builder = $this->createMock(ICalendarEventBuilder::class);
$builder->expects($this->once())->method('setOrganizer')
->with('alice@nextcloud.example');
$this->eventBuilderWith($builder);
$calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE);
$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' => 'boss@example.com',
'organizerEmail' => 'alice@nextcloud.example',
'location' => '',
'url' => '',
'attendees' => [],
];
$this->invoke($c, 'createEvent', [$calendar, $data]);
}
public function testCreateEventFallsBackToLinkOrganizer(): void
{
$builder = $this->createMock(ICalendarEventBuilder::class);
$builder->expects($this->once())->method('setOrganizer')
->with('boss@example.com');
$this->eventBuilderWith($builder);
$calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE);
$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' => 'boss@example.com',
'organizerEmail' => '',
'location' => '',
'url' => '',
'attendees' => [],
];
$this->invoke($c, 'createEvent', [$calendar, $data]);
}
/*
* create(): the user's email address reaches the event builder
*/
public function testCreateUsesUserEmailAsOrganizer(): void
{
$user = $this->mockUser('alice');
$user->method('getEMailAddress')->willReturn('alice@nextcloud.example');
$this->userSession->method('getUser')->willReturn($user);
$this->setUpWritableCalendars();
$builder = $this->createMock(ICalendarEventBuilder::class);
$builder->expects($this->once())->method('setOrganizer')
->with('alice@nextcloud.example');
$this->eventBuilderWith($builder);
$this->appManager->method('isEnabledForUser')->willReturn(true);
$this->urlGenerator->method('linkToRoute')->willReturn('/apps/calendar/');
$c = $this->newController();
$args = $this->validCreateArgs();
$args[] = '';
$response = $c->create(...$args);
$this->assertInstanceOf(RedirectResponse::class, $response);
}
/*
* 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']
);
}
public function testConfirmRegistersStylesheet(): void
{
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
$this->setUpWritableCalendars();
$this->urlGenerator->method('getAbsoluteURL')->willReturn('https://cloud.example.com/');
$c = $this->newController();
$c->confirm('Weekly sync', '', '2026-10-07T14:15', '2026-10-07T15:15');
$this->assertContains(
['atocal', 'style', false],
\OC_Util::$styles
);
}
/*
* parseAttendees()
*/
public function testParseAttendeesNormalizesInput(): void
{
$c = $this->newController();
$attendees = $this->invoke($c, 'parseAttendees', [
'alice@example.com, BOB@Example.com ; bob@example.com carl@example.com',
]);
$this->assertSame([
['email' => 'alice@example.com', 'name' => ''],
['email' => 'bob@example.com', 'name' => ''],
['email' => 'carl@example.com', 'name' => ''],
], $attendees);
}
public function testParseAttendeesEmptyInput(): void
{
$c = $this->newController();
$this->assertSame(
[],
$this->invoke($c, 'parseAttendees', [' '])
);
}
public function testParseAttendeesRejectsInvalidEmail(): void
{
$c = $this->newController();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('is not a valid email address.');
$this->invoke($c, 'parseAttendees', ['alice@example.com,not-an-email']);
}
public function testParseAttendeesEnforcesLimit(): void
{
$c = $this->newController();
$emails = implode(',', array_map(
static fn (int $i): string => "person$i@example.com",
range(1, 21)
));
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('A meeting can invite at most 20 people.');
$this->invoke($c, 'parseAttendees', [$emails]);
}
/*
* confirm(): contact suggestions for the datalist
*/
public function testConfirmOffersContactSuggestions(): void
{
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
$this->setUpWritableCalendars();
$this->urlGenerator->method('getAbsoluteURL')->willReturn('https://cloud.example.com/');
$this->contactsManager->method('search')->willReturn([
['FN' => 'Alice Example', 'EMAIL' => 'alice@example.com'],
['FN' => 'Bob', 'EMAIL' => ['bob@example.com', 'bob@work.example.com']],
]);
$c = $this->newController();
$response = $c->confirm(
'Weekly sync',
'',
'2026-10-07T14:15',
'2026-10-07T15:15'
);
$this->assertSame('confirm', $response->getTemplateName());
$this->assertSame([
['email' => 'alice@example.com', 'name' => 'Alice Example'],
['email' => 'bob@example.com', 'name' => 'Bob'],
['email' => 'bob@work.example.com', 'name' => 'Bob'],
], $response->getParams()['contacts']);
}
/*
* create(): invalid invitee input keeps the form usable
*/
public function testCreateInvalidAttendeeRendersConfirm(): void
{
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
$this->setUpWritableCalendars();
$args = $this->validCreateArgs();
$args[] = 'alice@example.com, not-an-email';
$c = $this->newController();
$response = $c->create(...$args);
$this->assertSame('confirm', $response->getTemplateName());
$params = $response->getParams();
$this->assertStringContainsString(
'is not a valid email address.',
$params['error']
);
$this->assertSame('alice@example.com, not-an-email', $params['attendees']);
$this->assertSame('5::personal', $params['calendarId']);
}
/*
* searchContacts()
*/
public function testSearchContactsReturnsMatches(): void
{
$this->contactsManager->expects($this->once())->method('search')
->with('ali', ['FN', 'EMAIL'])
->willReturn([
['FN' => 'Alice Example', 'EMAIL' => 'alice@example.com'],
]);
$c = $this->newController();
$response = $c->searchContacts('ali');
$this->assertInstanceOf(DataResponse::class, $response);
$this->assertSame(
[['email' => 'alice@example.com', 'name' => 'Alice Example']],
$response->getData()
);
}
public function testSearchContactsCapsResults(): void
{
$contacts = array_map(
static fn (int $i): array => [
'FN' => "Person $i",
'EMAIL' => "person$i@example.com",
],
range(1, 60)
);
$this->contactsManager->method('search')->willReturn($contacts);
$c = $this->newController();
$response = $c->searchContacts('person');
$this->assertCount(50, $response->getData());
}
}