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 $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 LiveService())->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 $filter = null, ?ISort $sort = null): array { $list = []; foreach ($this->mailboxes as $mailbox) { $list[$mailbox->name()] = (new CollectionResource('imap', 'svc'))->fromImap($mailbox); } return $list; } }