Files
provider_imap/tests/php/Unit/HarmonizationServiceTest.php
T
2026-10-05 20:34:02 -04:00

176 lines
6.7 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXT\ProviderImap\Tests\Unit;
use Generator;
use KTXC\Db\Collection;
use KTXC\Db\DataStore;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Sort\ISort;
use KTXM\ProviderImap\Client\Mailbox;
use KTXM\ProviderImap\Providers\CollectionResource;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Service\Cache\HarmonizationService;
use KTXM\ProviderImap\Service\Cache\MessageIngestor;
use KTXM\ProviderImap\Service\Live\LiveMailService;
use KTXM\ProviderImap\Stores\MailboxStore;
use KTXM\ProviderImap\Stores\MessageFileStore;
use KTXM\ProviderImap\Stores\MessageStore;
use LogicException;
use MongoDB\UpdateResult;
use PHPUnit\Framework\TestCase;
final class HarmonizationServiceTest extends TestCase
{
private array $calls = [];
public function testMailboxesAreAddedUpdatedAndRemoved(): void
{
$service = $this->harmonizer(
cached: ['INBOX' => [], 'Old' => []],
remote: ['INBOX', 'Reports'],
);
$result = $service->harmonizeMailboxes();
$this->assertSame(['added' => ['Reports'], 'updated' => ['INBOX'], 'removed' => ['Old']], $result);
$this->assertSame([
['upsert', 'tenant', 'svc', 'INBOX'],
['upsert', 'tenant', 'svc', 'Reports'],
['messages.deleteByMailbox', 'svc', 'Old'],
['files.deleteByMailbox', 'tenant', 'svc', 'Old'],
['mailboxes.delete', 'svc', 'Old'],
], $this->calls);
}
public function testNothingIsPurgedWhenTheServerListHasNoInbox(): void
{
$service = $this->harmonizer(
cached: ['INBOX' => [], 'Reports' => []],
remote: [],
);
$result = $service->harmonizeMailboxes();
$this->assertSame(['added' => [], 'updated' => [], 'removed' => []], $result);
$this->assertSame([], $this->calls);
}
public function testUnboundServiceIsRejected(): void
{
$unbound = new HarmonizationService(
$this->createStub(MailboxStore::class),
$this->createStub(MessageStore::class),
$this->createStub(MessageFileStore::class),
$this->createStub(MessageIngestor::class),
);
$this->expectException(LogicException::class);
$unbound->harmonizeMailboxes();
}
public function testLockIsTakenOnlyWhenFreeExpiredOrOwned(): void
{
$collection = $this->createMock(Collection::class);
$collection->expects($this->exactly(2))
->method('updateOne')
->willReturnCallback(function (array $filter, array $update) use (&$matched): UpdateResult {
$this->assertSame(['lock' => null], $filter['$or'][0]);
$this->assertArrayHasKey('$lte', $filter['$or'][1]['lock.expiresAt']);
$this->assertSame(['lock.owner' => 'run-1'], $filter['$or'][2]);
$this->assertSame('run-1', $update['$set']['lock']['owner']);
$result = $this->createStub(UpdateResult::class);
$result->method('getMatchedCount')->willReturn(array_shift($matched));
return $result;
});
$matched = [1, 0];
$store = new MailboxStore($this->dataStore($collection));
$this->assertTrue($store->acquireLock('svc', 'INBOX', 'run-1', 300));
$this->assertFalse($store->acquireLock('svc', 'INBOX', 'run-1', 300));
}
public function testStateFillsDefaultsAndIgnoresOtherFields(): void
{
$collection = $this->createStub(Collection::class);
$collection->method('findOne')->willReturn([
'sid' => 'svc', 'name' => 'INBOX', 'uidValidity' => 7, 'lock' => ['owner' => 'x'],
]);
$state = (new MailboxStore($this->dataStore($collection)))->state('svc', 'INBOX');
$this->assertSame([...MailboxStore::STATE_DEFAULTS, 'uidValidity' => 7], $state);
}
public function testNewMailboxStartsWithDefaultState(): void
{
$collection = $this->createMock(Collection::class);
$collection->expects($this->once())
->method('updateOne')
->with(
$this->anything(),
$this->callback(fn (array $update): bool => $update['$setOnInsert'] === MailboxStore::STATE_DEFAULTS
&& array_intersect_key($update['$set'], MailboxStore::STATE_DEFAULTS) === []),
['upsert' => true],
);
$mailbox = (new CollectionResource('imap', 'svc'))->fromImap(new Mailbox('INBOX', '/', []));
(new MailboxStore($this->dataStore($collection)))->upsert('tenant', 'svc', $mailbox);
}
/**
* @param array<string, array> $cached
* @param string[] $remote
*/
private function harmonizer(array $cached, array $remote): HarmonizationService
{
$mailboxes = $this->createStub(MailboxStore::class);
$mailboxes->method('list')->willReturn($cached);
$mailboxes->method('upsert')->willReturnCallback(function (string $tid, string $sid, $collection): void {
$this->calls[] = ['upsert', $tid, $sid, $collection->identifier()];
});
$mailboxes->method('delete')->willReturnCallback(function (string $sid, string $name): void {
$this->calls[] = ['mailboxes.delete', $sid, $name];
});
$messages = $this->createStub(MessageStore::class);
$messages->method('deleteByMailbox')->willReturnCallback(function (string $sid, string $name): void {
$this->calls[] = ['messages.deleteByMailbox', $sid, $name];
});
$files = $this->createStub(MessageFileStore::class);
$files->method('deleteByMailbox')->willReturnCallback(function (string $tid, string $sid, string $name): void {
$this->calls[] = ['files.deleteByMailbox', $tid, $sid, $name];
});
$service = (new Service())->fromStore(['tid' => 'tenant', 'sid' => 'svc']);
$live = new HarmonizationServiceTestLiveStub($service);
$live->mailboxes = array_map(static fn (string $name): Mailbox => new Mailbox($name, '/', []), $remote);
return (new HarmonizationService($mailboxes, $messages, $files, $this->createStub(MessageIngestor::class)))->for($service, $live);
}
private function dataStore(Collection $collection): DataStore
{
$dataStore = $this->createStub(DataStore::class);
$dataStore->method('selectCollection')->willReturn($collection);
return $dataStore;
}
}
final class HarmonizationServiceTestLiveStub extends LiveMailService
{
/** @var Mailbox[] */
public array $mailboxes = [];
public function collectionList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator
{
foreach ($this->mailboxes as $mailbox) {
yield $mailbox->name() => $mailbox;
}
}
}