[Web][Tests] Add PHPUnit setup and imapsync module tests

This commit is contained in:
FreddleSpl0it committed 2026-10-22 10:07:52 +02:00
1 parent b2a152fe45
commit e756b7857e
18 files changed
+3948 -1

No files matched your search

@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Imapsync\Controller;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use Mailcow\Imapsync\Controller\ImapsyncApiController;
use Mailcow\Imapsync\Model\Job;
use Mailcow\Imapsync\Model\Settings;
use Mailcow\Imapsync\Model\Source;
use Mailcow\Imapsync\Service\JobService;
use Mailcow\Imapsync\Service\SourceService;
use PDOException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ApiControllerTest extends TestCase
{
private SourceService&MockObject $sources;
private JobService&MockObject $jobs;
private ImapsyncApiController $api;
protected function setUp(): void
{
$_SESSION = [];
$this->sources = $this->createMock(SourceService::class);
$this->jobs = $this->createMock(JobService::class);
$this->api = new ImapsyncApiController($this->sources, $this->jobs, new ApiResponse(['password1', 'oauth_client_secret']));
}
private static function job(string $user2 = 'local@example.org', array $row = []): Job
{
return Job::fromRow($row + ['id' => 1, 'user2' => $user2, 'user1' => 'remote', 'source_id' => 5]);
}
private static function source(string $name = 'src'): Source
{
return Source::fromRow(['id' => 5, 'name' => $name]);
}
/**
* Messages written to the session as [type, msg].
*/
private function messages(): array
{
return array_map(fn(array $m): array => [$m['type'], $m['msg']], $_SESSION['return'] ?? []);
}
public function testAddJobSuccessIsLoggedWithoutSecrets(): void
{
$this->jobs->method('create')->willReturn(self::job('new@example.org'));
$this->assertTrue($this->api->addJob(['username' => 'new@example.org', 'password1' => 'pw']));
$this->assertSame([[
'type' => 'success',
'log' => ['syncjob', 'add', 'job', ['username' => 'new@example.org', 'password1' => '*']],
'msg' => ['mailbox_modified', 'new@example.org'],
]], $_SESSION['return']);
}
public function testUserErrorAndDatabaseErrorBecomeDangerMessages(): void
{
$this->jobs->method('create')->willReturnOnConsecutiveCalls(
$this->throwException(new UserError('object_exists', ['src / remote'])),
$this->throwException(new PDOException('deadlock'))
);
$this->assertFalse($this->api->addJob([]));
$this->assertFalse($this->api->addJob([]));
$this->assertSame([['danger', ['object_exists', 'src / remote']], ['danger', ['mysql_error', 'deadlock']]], $this->messages());
}
public function testAddSource(): void
{
$this->sources->method('create')->willReturn(self::source('new'));
$this->assertTrue($this->api->addSource(['name' => 'new', 'oauth_client_secret' => 's']));
$this->assertSame([['success', ['imapsync_source_added', 'new']]], $this->messages());
$this->assertSame('*', $_SESSION['return'][0]['log'][3]['oauth_client_secret']);
}
public function testEditJobsKeepsBatchMessageOrder(): void
{
$this->jobs->method('update')->willReturnCallback(function (int $id, array $data): Job {
if ($id === 2) {
throw new UserError('imapsync_source_unavailable');
}
return self::job('mb' . $id . '@example.org');
});
$this->assertTrue($this->api->editJobs([1, 'x', 2, '3', 1.5], ['active' => 0]));
$this->assertSame([
['success', ['mailbox_modified', 'mb1@example.org']],
['danger', 'access_denied'],
['danger', 'imapsync_source_unavailable'],
['success', ['mailbox_modified', 'mb3@example.org']],
['danger', 'access_denied'],
], $this->messages());
$this->assertSame(['syncjob', 'edit', 'job', ['id' => [1, 'x', 2, '3', 1.5], 'active' => 0]], $_SESSION['return'][0]['log']);
}
public function testEditJobsIdInAttributesWinsLikeArrayMerge(): void
{
$this->jobs->expects($this->once())->method('update')->with(7, ['id' => 7])->willReturn(self::job());
$this->assertTrue($this->api->editJobs([1, 2], ['id' => 7]));
}
public function testEditJobsWithoutAclIsOneRejection(): void
{
$this->jobs->method('requireAcl')->willThrowException(new AccessDenied());
$this->jobs->expects($this->never())->method('update');
$this->assertFalse($this->api->editJobs([1, 2], []));
$this->assertSame([['danger', 'access_denied']], $this->messages());
}
public function testDatabaseErrorStopsTheBatch(): void
{
$this->jobs->method('update')->willReturnOnConsecutiveCalls(self::job(), $this->throwException(new PDOException('gone')));
$this->assertFalse($this->api->editJobs([1, 2, 3], []));
$this->assertSame([['success', ['mailbox_modified', 'local@example.org']], ['danger', ['mysql_error', 'gone']]], $this->messages());
}
public function testEditJobsWithoutItemsDoesNothing(): void
{
$this->jobs->expects($this->never())->method('update');
$this->assertTrue($this->api->editJobs(null, ['active' => 1]));
$this->assertSame([], $this->messages());
}
public function testRunNextReportsRunningJobsThenTheMove(): void
{
$this->jobs->expects($this->once())->method('runNext')->with([4, 1, 2])->willReturn(['running' => [4], 'moved' => 2]);
$this->assertTrue($this->api->runNext(['4', 'x', 1.7, 2]));
$this->assertSame([['danger', ['imapsync_job_running', 4]], ['success', ['imapsync_run_next_done', 2]]], $this->messages());
$this->assertSame(['syncjob', 'edit', 'run_next', ['id' => ['4', 'x', 1.7, 2]]], $_SESSION['return'][0]['log']);
}
public function testRunNextWithoutMoveReturnsFalse(): void
{
$this->jobs->method('runNext')->willReturnOnConsecutiveCalls(['running' => [], 'moved' => 0], $this->throwException(new AccessDenied()));
$this->assertFalse($this->api->runNext([9]));
$this->assertSame([], $this->messages());
$this->assertFalse($this->api->runNext([9]));
$this->assertSame([['danger', 'access_denied']], $this->messages());
}
public function testEditSourcesSkipsNonNumericIds(): void
{
$this->sources->method('update')->willReturnCallback(function (int $id): Source {
if ($id === 3) {
throw new AccessDenied();
}
return self::source('s' . $id);
});
$this->assertTrue($this->api->editSources(['a', 2, 3, '1.5'], ['name' => 'x']));
$this->assertSame([['success', ['imapsync_source_modified', 's2']], ['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages());
}
public function testEditSourcesNeedsAnEditor(): void
{
$this->sources->method('requireEditor')->willThrowException(new AccessDenied());
$this->assertFalse($this->api->editSources([1], []));
$this->assertSame([['danger', 'access_denied']], $this->messages());
}
public function testRefreshTokenUsesTheFirstId(): void
{
$this->sources->expects($this->once())->method('refreshToken')->with(6)->willReturn(self::source('m365'));
$this->assertTrue($this->api->refreshSourceToken(['6', '7']));
$this->assertFalse($this->api->refreshSourceToken([]));
$this->assertFalse($this->api->refreshSourceToken(null));
$this->assertSame([['success', ['imapsync_source_token_refreshed', 'm365']], ['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages());
$this->assertSame(['syncjob', 'edit', 'refresh_token', ['id' => ['6', '7']]], $_SESSION['return'][0]['log']);
}
public function testEditSettings(): void
{
$this->jobs->method('saveSettings')->willReturnOnConsecutiveCalls(new Settings(), $this->throwException(new UserError('imapsync_max_parallel_invalid')));
$this->assertTrue($this->api->editSettings(['max_parallel' => 2]));
$this->assertFalse($this->api->editSettings(['max_parallel' => 0]));
$this->assertSame([['success', 'max_parallel_saved'], ['danger', 'imapsync_max_parallel_invalid']], $this->messages());
$this->assertSame(['syncjob', 'edit', 'settings', ['max_parallel' => 2]], $_SESSION['return'][0]['log']);
}
public function testDeleteJobsUsesTheRequestIdInTheMessage(): void
{
$this->jobs->method('delete')->willReturnCallback(function (int $id): void {
if ($id === 8) {
throw new AccessDenied();
}
});
$this->assertTrue($this->api->deleteJobs(['5', 8, 'x']));
$this->assertSame([['success', ['deleted_syncjob', '5']], ['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages());
}
public function testDeleteSources(): void
{
$this->sources->method('delete')->willReturnCallback(function (int $id): Source {
if ($id === 2) {
throw new UserError('imapsync_source_in_use', ['busy']);
}
return self::source('gone');
});
$this->assertTrue($this->api->deleteSources([1, 'x', 2]));
$this->assertSame([['success', ['imapsync_source_deleted', 'gone']], ['danger', ['imapsync_source_in_use', 'busy']]], $this->messages());
$_SESSION = [];
$this->sources->method('requireAcl')->willThrowException(new AccessDenied());
$this->assertFalse($this->api->deleteSources([1]));
$this->assertSame([['danger', 'access_denied']], $this->messages());
}
public function testReadRoutes(): void
{
$source = self::source();
$this->jobs->method('customParamOptions')->willReturn(['debug' => false]);
$this->jobs->method('getSettings')->willReturn(new Settings());
$this->sources->method('visibleIds')->willReturn([5, 6]);
$this->sources->method('get')->willReturnMap([[5, $source], [6, null]]);
$this->sources->method('toArray')->with($source)->willReturn(['id' => 5]);
$this->assertSame(['debug' => false], $this->api->getOptions());
$this->assertSame(['max_parallel' => 1, 'max_bytes_per_second' => 0, 'max_kb_per_second' => 0], $this->api->getSettings());
$this->assertSame([['id' => 5]], $this->api->getSources());
$this->assertSame(['id' => 5], $this->api->getSource('5'));
$this->assertSame([], $this->api->getSource('abc'));
$this->assertSame([], $this->api->getSource(6));
$this->assertSame([], $this->messages());
}
public function testSettingsAreEmptyForNonAdmins(): void
{
$this->jobs->method('getSettings')->willThrowException(new AccessDenied());
$this->assertSame([], $this->api->getSettings());
}
public function testJobRoutesHonourNoLog(): void
{
$job = self::job('local@example.org', ['returned_text' => 'log text', 'password1' => 'secret']);
$this->jobs->method('idsForMailbox')->with('local@example.org')->willReturn([1]);
$this->jobs->expects($this->exactly(2))->method('get')
->willReturnCallback(fn(int $id, bool $withLog): Job => $job);
$withLog = $this->api->getJobs('local@example.org', null);
$withoutLog = $this->api->getJob('1', 'no_log,with_password');
$this->assertSame('log text', $withLog[0]['log']);
$this->assertSame('', $withoutLog['log']);
$this->assertArrayNotHasKey('password1', $withLog[0]);
$this->assertArrayNotHasKey('returned_text', $withLog[0]);
}
public function testAllJobsForAdminsAndOthers(): void
{
$jobs = [
1 => self::job('a@example.org', ['id' => 1, 'prio' => 0, 'last_run' => '2026-10-01 10:00:00']),
2 => self::job('b@example.org', ['id' => 2, 'prio' => 0]),
3 => self::job('a@example.org', ['id' => 3, 'prio' => 5, 'last_run' => '2026-10-02 10:00:00']),
4 => self::job('b@example.org', ['id' => 4, 'prio' => 0, 'last_run' => '2026-09-01 10:00:00']),
];
$this->jobs->method('canListAll')->willReturnOnConsecutiveCalls(true, false);
$this->jobs->method('allIds')->willReturn([3, 2, 4, 1]);
$this->jobs->method('idsForMailbox')->willReturnMap([['a@example.org', [3, 1]], ['b@example.org', [2, 4]]]);
$this->jobs->method('get')->willReturnCallback(fn(int $id): ?Job => $jobs[$id] ?? null);
$this->assertSame([3, 2, 4, 1], array_column($this->api->getAllJobs(null, []), 'id'));
$this->assertSame([3, 2, 4, 1], array_column($this->api->getAllJobs('no_log', ['a@example.org', 'b@example.org']), 'id'));
}
public function testAccountChangedOutsideTransactionReportsDatabaseErrors(): void
{
$this->sources->expects($this->once())->method('onAccountRenamed')->with('old@example.org', 'new@example.org');
$this->sources->method('onAccountDeleted')->willThrowException(new PDOException('locked'));
$this->api->accountChanged('old@example.org', 'new@example.org');
$this->assertSame([], $this->messages());
$this->api->accountChanged('old@example.org');
$this->assertSame([[
'type' => 'danger',
'log' => ['imapsync_account_changed', 'old@example.org', null],
'msg' => ['mysql_error', 'locked'],
]], $_SESSION['return']);
}
}