nextcloud/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php
nextcloud-command 5eec4fd69c refactor: Apply rector changes
Signed-off-by: GitHub <noreply@github.com>
2026-03-15 14:53:03 +00:00

638 lines
22 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\integration\UserMigration;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\CalendarImpl;
use OCA\DAV\UserMigration\CalendarMigrator;
use OCP\AppFramework\App;
use OCP\Calendar\IManager as ICalendarManager;
use OCP\IUser;
use OCP\IUserManager;
use OCP\UserMigration\IExportDestination;
use OCP\UserMigration\IImportSource;
use Sabre\VObject\UUIDUtil;
use Symfony\Component\Console\Output\OutputInterface;
use Test\TestCase;
#[\PHPUnit\Framework\Attributes\Group(name: 'DB')]
class CalendarMigratorTest extends TestCase {
private IUserManager $userManager;
private ICalendarManager $calendarManager;
private CalDavBackend $calDavBackend;
private CalendarMigrator $migrator;
private OutputInterface $output;
private const ASSETS_DIR = __DIR__ . '/assets/calendars/';
private const USERS_URI_ROOT = 'principals/users/';
protected function setUp(): void {
parent::setUp();
$app = new App(Application::APP_ID);
$container = $app->getContainer();
$this->userManager = $container->get(IUserManager::class);
$this->calendarManager = $container->get(ICalendarManager::class);
$this->calDavBackend = $container->get(CalDavBackend::class);
$this->migrator = $container->get(CalendarMigrator::class);
$this->output = $this->createMock(OutputInterface::class);
}
private function createTestUser(): IUser {
$userId = UUIDUtil::getUUID();
return $this->userManager->createUser($userId, 'topsecretpassword');
}
private function deleteUser(IUser $user): void {
$user->delete();
}
private function getCalendarsForUser(IUser $user): array {
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri);
return array_filter($calendars, fn ($c) => $c instanceof CalendarImpl && !$c->isShared());
}
public function testImportV1(): void {
$user = $this->createTestUser();
try {
// Get all asset files
$files = scandir(self::ASSETS_DIR);
$this->assertNotFalse($files, 'Failed to scan assets directory');
$files = array_values(array_diff($files, ['.', '..']));
$this->assertNotEmpty($files, 'No asset files found');
// Load all ICS content
$icsContents = [];
foreach ($files as $filename) {
$icsContents[$filename] = file_get_contents(self::ASSETS_DIR . $filename);
}
// Setup import source mock
$importSource = $this->createMock(IImportSource::class);
$importSource->method('getMigratorVersion')
->with('calendar')
->willReturn(1);
$importSource->method('getFolderListing')
->with('dav/calendars/')
->willReturn($files);
$importSource->method('getFileAsStream')
->willReturnCallback(function (string $path) use ($icsContents) {
foreach ($icsContents as $filename => $content) {
if ($path === 'dav/calendars/' . $filename) {
$stream = fopen('php://temp', 'r+');
fwrite($stream, $content);
rewind($stream);
return $stream;
}
}
throw new \Exception("Unexpected path: $path");
});
// Import all calendars
$this->migrator->import($user, $importSource, $this->output);
// Verify all calendars were created
$calendars = $this->getCalendarsForUser($user);
$this->assertCount(count($files), $calendars, 'Expected all calendars to be created');
// Verify each calendar has the migrated prefix and has objects
foreach ($files as $filename) {
$expectedUri = 'migrated-' . substr($filename, 0, -4);
$found = false;
foreach ($calendars as $calendar) {
if ($calendar->getUri() === $expectedUri) {
$found = true;
// Verify calendar has objects
$objects = $this->calDavBackend->getCalendarObjects((int)$calendar->getKey());
$this->assertNotEmpty($objects, "Expected calendar $expectedUri to have objects");
break;
}
}
$this->assertTrue($found, "Calendar with URI $expectedUri was not found");
}
} finally {
$this->deleteUser($user);
}
}
public function testImportV2(): void {
$user = $this->createTestUser();
try {
// Get all asset files
$files = scandir(self::ASSETS_DIR);
$this->assertNotFalse($files, 'Failed to scan assets directory');
$files = array_values(array_diff($files, ['.', '..']));
$this->assertNotEmpty($files, 'No asset files found');
// Load all ICS content and build calendars metadata
$calendarsMetadata = [];
$icsContents = [];
foreach ($files as $filename) {
$icsContent = file_get_contents(self::ASSETS_DIR . $filename);
$calendarUri = substr($filename, 0, -4);
$icsContents[$calendarUri] = $icsContent;
$calendarsMetadata[] = [
'format' => 'ical',
'uri' => $calendarUri,
'label' => $calendarUri,
'color' => '#0082c9',
'timezone' => null,
];
}
// Setup import source mock for V2 format (calendars.json + .data files)
$importSource = $this->createMock(IImportSource::class);
$calendarsJson = json_encode($calendarsMetadata);
$importSource->method('getMigratorVersion')
->with('calendar')
->willReturn(2);
$importSource->method('pathExists')
->willReturnCallback(fn (string $path) => $path === 'dav/calendars/calendars.json');
$importSource->method('getFileContents')
->willReturnCallback(function (string $path) use ($calendarsJson) {
if ($path === 'dav/calendars/calendars.json') {
return $calendarsJson;
}
throw new \Exception("Unexpected path: $path");
});
$importSource->method('getFileAsStream')
->willReturnCallback(function (string $path) use ($icsContents) {
foreach ($icsContents as $calendarUri => $icsContent) {
if ($path === 'dav/calendars/' . $calendarUri . '.data') {
$stream = fopen('php://temp', 'r+');
fwrite($stream, $icsContent);
rewind($stream);
return $stream;
}
}
throw new \Exception("Unexpected path: $path");
});
// Import all calendars
$this->migrator->import($user, $importSource, $this->output);
// Verify all calendars were created
$calendars = $this->getCalendarsForUser($user);
$this->assertCount(count($files), $calendars, 'Expected all calendars to be created');
// Verify each calendar has the correct properties and objects
foreach ($calendarsMetadata as $metadata) {
$expectedUri = 'migrated-' . $metadata['uri'];
$found = false;
foreach ($calendars as $calendar) {
if ($calendar->getUri() === $expectedUri) {
$found = true;
// Verify calendar display name
$this->assertEquals($metadata['label'], $calendar->getDisplayName());
// Verify calendar has objects
$objects = $this->calDavBackend->getCalendarObjects((int)$calendar->getKey());
$this->assertNotEmpty($objects, "Expected calendar $expectedUri to have objects");
break;
}
}
$this->assertTrue($found, "Calendar with URI $expectedUri was not found");
}
} finally {
$this->deleteUser($user);
}
}
public function testExport(): void {
$user = $this->createTestUser();
try {
// Create a calendar to export
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$calendarUri = 'test-export-calendar';
$calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [
'{DAV:}displayname' => 'Test Export Calendar',
'{http://apple.com/ns/ical/}calendar-color' => '#ff0000',
]);
// Add an event to the calendar
$icsContent = file_get_contents(self::ASSETS_DIR . 'event-timed.ics');
$this->calDavBackend->createCalendarObject($calendarId, 'test-event.ics', $icsContent);
// Setup export destination mock
$exportDestination = $this->createMock(IExportDestination::class);
$exportedCalendarsJson = null;
$exportedData = null;
$exportDestination->method('addFileContents')
->willReturnCallback(function (string $path, string $content) use (&$exportedCalendarsJson): void {
if ($path === 'dav/calendars/calendars.json') {
$exportedCalendarsJson = json_decode($content, true);
}
});
$exportDestination->method('addFileAsStream')
->willReturnCallback(function (string $path, $stream) use (&$exportedData): void {
if (str_ends_with($path, '.data')) {
$exportedData = stream_get_contents($stream);
}
});
// Export the calendar
$this->migrator->export($user, $exportDestination, $this->output);
// Verify calendars.json was exported
$this->assertNotNull($exportedCalendarsJson, 'Expected calendars.json to be exported');
$this->assertIsArray($exportedCalendarsJson);
$this->assertCount(1, $exportedCalendarsJson);
$exportedMeta = $exportedCalendarsJson[0];
$this->assertEquals('ical', $exportedMeta['format']);
$this->assertEquals($calendarUri, $exportedMeta['uri']);
$this->assertEquals('Test Export Calendar', $exportedMeta['label']);
$this->assertEquals('#ff0000', $exportedMeta['color']);
// Verify data was exported
$this->assertNotNull($exportedData, 'Expected data to be exported');
$this->assertIsString($exportedData);
/** @var string $exportedData */
$this->assertStringContainsString('BEGIN:VCALENDAR', $exportedData);
$this->assertStringContainsString('BEGIN:VEVENT', $exportedData);
} finally {
$this->deleteUser($user);
}
}
public function testExportImportRoundTrip(): void {
$user = $this->createTestUser();
try {
// Create a calendar with some events
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$calendarUri = 'roundtrip-calendar';
$calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [
'{DAV:}displayname' => 'Round Trip Calendar',
'{http://apple.com/ns/ical/}calendar-color' => '#00ff00',
]);
// Add events to the calendar
$icsContent = file_get_contents(self::ASSETS_DIR . 'event-timed.ics');
$this->calDavBackend->createCalendarObject($calendarId, 'event1.ics', $icsContent);
// Capture exported data
$exportedFiles = [];
$exportDestination = $this->createMock(IExportDestination::class);
$exportDestination->method('addFileContents')
->willReturnCallback(function (string $path, string $content) use (&$exportedFiles): void {
$exportedFiles[$path] = $content;
});
$exportDestination->method('addFileAsStream')
->willReturnCallback(function (string $path, $stream) use (&$exportedFiles): void {
$exportedFiles[$path] = stream_get_contents($stream);
});
// Export
$this->migrator->export($user, $exportDestination, $this->output);
// Delete the original calendar
$this->calDavBackend->deleteCalendar($calendarId, true);
// Verify calendar is gone
$calendars = $this->getCalendarsForUser($user);
$this->assertEmpty($calendars, 'Calendar should be deleted');
// Setup import source from exported data
$importSource = $this->createMock(IImportSource::class);
$importSource->method('getMigratorVersion')
->with('calendar')
->willReturn(2);
$importSource->method('pathExists')
->willReturnCallback(function (string $path) use ($exportedFiles) {
return isset($exportedFiles[$path]);
});
$importSource->method('getFolderListing')
->with('dav/calendars/')
->willReturn(array_map(fn ($p) => basename($p), array_keys($exportedFiles)));
$importSource->method('getFileContents')
->willReturnCallback(function (string $path) use ($exportedFiles) {
if (isset($exportedFiles[$path])) {
return $exportedFiles[$path];
}
throw new \Exception("File not found: $path");
});
$importSource->method('getFileAsStream')
->willReturnCallback(function (string $path) use ($exportedFiles) {
if (isset($exportedFiles[$path])) {
$stream = fopen('php://temp', 'r+');
fwrite($stream, $exportedFiles[$path]);
rewind($stream);
return $stream;
}
throw new \Exception("File not found: $path");
});
// Import
$this->migrator->import($user, $importSource, $this->output);
// Verify calendar was recreated with migrated prefix
$calendars = $this->getCalendarsForUser($user);
$this->assertCount(1, $calendars, 'Expected one calendar after import');
$calendar = reset($calendars);
$this->assertEquals('migrated-' . $calendarUri, $calendar->getUri());
$this->assertEquals('Round Trip Calendar', $calendar->getDisplayName());
// Verify events were imported
$objects = $this->calDavBackend->getCalendarObjects((int)$calendar->getKey());
$this->assertCount(1, $objects, 'Expected one event after import');
} finally {
$this->deleteUser($user);
}
}
public function testGetEstimatedExportSize(): void {
$user = $this->createTestUser();
try {
// Initially should be 0 or minimal
$initialSize = $this->migrator->getEstimatedExportSize($user);
$this->assertEquals(0, $initialSize);
// Create a calendar with events
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$calendarUri = 'size-test-calendar';
$calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [
'{DAV:}displayname' => 'Size Test Calendar',
]);
// Add an event
$icsContent = file_get_contents(self::ASSETS_DIR . 'event-timed.ics');
$this->calDavBackend->createCalendarObject($calendarId, 'event.ics', $icsContent);
// Size should now be > 0
$sizeWithData = $this->migrator->getEstimatedExportSize($user);
$this->assertGreaterThan(0, $sizeWithData);
} finally {
$this->deleteUser($user);
}
}
public function testImportExistingCalendarSkipped(): void {
$user = $this->createTestUser();
try {
$principalUri = self::USERS_URI_ROOT . $user->getUID();
// Pre-create a calendar with the migrated prefix
$calendarUri = 'migrated-existing-calendar';
$this->calDavBackend->createCalendar($principalUri, $calendarUri, [
'{DAV:}displayname' => 'Existing Calendar',
]);
// Setup import for V2
$importSource = $this->createMock(IImportSource::class);
$importSource->method('getMigratorVersion')
->with('calendar')
->willReturn(2);
$importSource->method('pathExists')
->willReturnCallback(function (string $path) {
if ($path === 'dav/calendars/calendars.json') {
return true;
}
if ($path === 'dav/calendars/subscriptions.json') {
return false;
}
return false;
});
$importSource->method('getFileContents')
->willReturnCallback(function (string $path) {
if ($path === 'dav/calendars/calendars.json') {
return json_encode([[
'format' => 'ical',
'uri' => 'existing-calendar',
'label' => 'Existing Calendar',
'color' => '#0082c9',
'timezone' => null,
]]);
}
throw new \Exception("Unexpected path: $path");
});
// Import should use existing calendar
$this->migrator->import($user, $importSource, $this->output);
// Should still have just one calendar
$calendars = $this->getCalendarsForUser($user);
$this->assertCount(1, $calendars);
} finally {
$this->deleteUser($user);
}
}
public function testExportSubscriptions(): void {
$user = $this->createTestUser();
try {
// Create a subscription to export
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$this->calDavBackend->createSubscription(
$principalUri,
'test-subscription',
[
'{http://calendarserver.org/ns/}source' => new \Sabre\DAV\Xml\Property\Href('https://example.com/calendar.ics'),
'{DAV:}displayname' => 'Test Subscription',
'{http://apple.com/ns/ical/}calendar-color' => '#ff0000',
'{http://calendarserver.org/ns/}subscribed-strip-todos' => '1',
]
);
// Setup export destination mock
$exportDestination = $this->createMock(IExportDestination::class);
$exportedSubscriptionsJson = null;
$exportDestination->method('addFileContents')
->willReturnCallback(function (string $path, string $content) use (&$exportedSubscriptionsJson): void {
if ($path === 'dav/calendars/subscriptions.json') {
$exportedSubscriptionsJson = json_decode($content, true);
}
});
$exportDestination->method('addFileAsStream');
// Export
$this->migrator->export($user, $exportDestination, $this->output);
// Verify exported subscription data
$this->assertNotNull($exportedSubscriptionsJson, 'Subscriptions JSON should be exported');
$this->assertCount(1, $exportedSubscriptionsJson, 'Expected one subscription in export');
$exportedSubscription = $exportedSubscriptionsJson[0];
$this->assertEquals('test-subscription', $exportedSubscription['uri']);
$this->assertEquals('Test Subscription', $exportedSubscription['displayname']);
$this->assertEquals('#ff0000', $exportedSubscription['color']);
$this->assertEquals('https://example.com/calendar.ics', $exportedSubscription['source']);
$this->assertEquals('1', $exportedSubscription['striptodos']);
} finally {
$this->deleteUser($user);
}
}
public function testImportSubscriptions(): void {
$user = $this->createTestUser();
try {
// Setup import source mock
$importSource = $this->createMock(IImportSource::class);
$subscriptionsJson = json_encode([[
'uri' => 'imported-subscription',
'displayname' => 'Imported Subscription',
'color' => '#00ff00',
'source' => 'https://example.com/imported.ics',
'striptodos' => null,
'stripalarms' => '1',
'stripattachments' => null,
]]);
$importSource->method('getMigratorVersion')
->with('calendar')
->willReturn(2);
$importSource->method('pathExists')
->willReturnCallback(function (string $path) {
if ($path === 'dav/calendars/subscriptions.json') {
return true;
}
if ($path === 'dav/calendars/calendars.json') {
return false;
}
return false;
});
$importSource->method('getFileContents')
->willReturnCallback(function (string $path) use ($subscriptionsJson) {
if ($path === 'dav/calendars/subscriptions.json') {
return $subscriptionsJson;
}
if ($path === 'dav/calendars/calendars.json') {
// Return empty calendars array
return json_encode([]);
}
throw new \Exception("Unexpected path: $path");
});
// Import
$this->migrator->import($user, $importSource, $this->output);
// Verify subscription was created
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri);
$this->assertCount(1, $subscriptions);
$subscription = $subscriptions[0];
$this->assertEquals('migrated-imported-subscription', $subscription['uri']);
$this->assertEquals('Imported Subscription', $subscription['{DAV:}displayname']);
$this->assertEquals('#00ff00', $subscription['{http://apple.com/ns/ical/}calendar-color']);
$this->assertEquals('1', $subscription['{http://calendarserver.org/ns/}subscribed-strip-alarms']);
} finally {
$this->deleteUser($user);
}
}
public function testExportImportSubscriptionsRoundTrip(): void {
$user = $this->createTestUser();
try {
// Create subscriptions to export
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$this->calDavBackend->createSubscription(
$principalUri,
'roundtrip-subscription',
[
'{http://calendarserver.org/ns/}source' => new \Sabre\DAV\Xml\Property\Href('https://example.com/roundtrip.ics'),
'{DAV:}displayname' => 'Round Trip Subscription',
'{http://apple.com/ns/ical/}calendar-color' => '#0000ff',
]
);
// Capture exported data
$exportedFiles = [];
$exportDestination = $this->createMock(IExportDestination::class);
$exportDestination->method('addFileContents')
->willReturnCallback(function (string $path, string $content) use (&$exportedFiles): void {
$exportedFiles[$path] = $content;
});
$exportDestination->method('addFileAsStream');
// Export
$this->migrator->export($user, $exportDestination, $this->output);
// Delete the original subscription
$subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri);
foreach ($subscriptions as $subscription) {
$this->calDavBackend->deleteSubscription($subscription['id']);
}
// Verify subscription is gone
$subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri);
$this->assertEmpty($subscriptions, 'Subscription should be deleted');
// Setup import source from exported data
$importSource = $this->createMock(IImportSource::class);
$importSource->method('getMigratorVersion')
->with('calendar')
->willReturn(2);
$importSource->method('pathExists')
->willReturnCallback(function (string $path) use ($exportedFiles) {
return isset($exportedFiles[$path]);
});
$importSource->method('getFileContents')
->willReturnCallback(function (string $path) use ($exportedFiles) {
if (isset($exportedFiles[$path])) {
return $exportedFiles[$path];
}
// Return empty for missing files
if ($path === 'dav/calendars/calendars.json') {
return json_encode([]);
}
throw new \Exception("File not found: $path");
});
// Import
$this->migrator->import($user, $importSource, $this->output);
// Verify subscription was recreated with migrated prefix
$subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri);
$this->assertCount(1, $subscriptions, 'Expected one subscription after import');
$subscription = $subscriptions[0];
$this->assertEquals('migrated-roundtrip-subscription', $subscription['uri']);
$this->assertEquals('Round Trip Subscription', $subscription['{DAV:}displayname']);
$this->assertEquals('#0000ff', $subscription['{http://apple.com/ns/ical/}calendar-color']);
} finally {
$this->deleteUser($user);
}
}
}