diff --git a/doc/todo.md b/doc/todo.md index 2a5a3121..617bf97c 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -167,51 +167,33 @@ when a migration plan is written. to stop converting between them at runtime. - Consolidate folder UID ↔ backend ID mapping (today split across `Horde_ActiveSync_Collections` and the driver). -- **Folder UID map vs volatile folder cache (long-term)** - - When the folder hierarchy changes (e.g. multiplexed Turba address books - added/removed from ActiveSync), clients may issue concurrent EAS sessions. - `FolderSync` with `synckey=0` clears the per-device folder cache - (`State/Base.php` → `_resetDeviceState()` → `SyncCache::clearFolders()`). - The backend-id → EAS-folder-UID map lives in that cache - (`getFolderUidToBackendIdMap()`), so concurrent rebuilds can race on an - empty map while `FolderSync` is still exporting. - - #81 (2026-07-09) mitigates this with deterministic UID generation when the - map is empty; see **Recently completed**. The structural issue remains: - folder identity should not depend on a cache that is wiped mid-rebuild. - - **Long-term direction:** - - 1. **Persist UID assignments separately** — e.g. `(device_id, user, - backend_serverid)` → `eas_folder_uid`, updated on first assignment and - rename (`old_id` in `_getFolderUidForBackendId()`), not cleared by - `clearFolders()`. Hierarchy diff drives FolderSync Add/Update/Remove; - drop map entries only when the backend folder is gone. - - 2. **Or: atomic folder-cache rebuild** — build the new hierarchy in a - staging structure, swap in one `SyncCache::save()`; no empty-map window - visible to parallel SYNC/PING/FolderSync requests. - - 3. **Fold into Horde 6 storage refactor** — overlaps “Unify serverid vs - backend folder names” and “Fold SyncCache into device object” above. - - **Key code paths:** `Driver/Base.php` (`_getFolderUidForBackendId`, - `_tempMap`), `State/Base.php` (`getFolderUidToBackendIdMap`, `loadState` - synckey `0`), `SyncCache.php` (`clearFolders`, `updateFolder`), - `Collections.php` (`getBackendIdForFolderUid`, `initHierarchySync`, `save`), - `Request/FolderSync.php`. - - **Done when:** concurrent `FolderSync` `synckey=0` after a hierarchy - change yields one consistent saved UID map; devices converge without account - removal; renames preserve UID. Do not drop #81 deterministic generation - until (1) or (2) is in place. - - **After structural fix:** Prefer switching back to opaque (e.g. random) - UIDs for *new* map entries. Deterministic derivation was only needed so - parallel rebuilds agreed while the map was empty; a persisted map assigns - each backend folder once and removes that race. Opaque IDs also avoid - leaking backend folder identity via a predictable `crc32(prefix:id)` scheme. +- **Folder UID map vs volatile folder cache** + + **Done:** Persistent SyncCache `foldermap` (`backend_serverid` → + `eas_folder_uid`) survives `clearFolders()`. FolderSync `synckey=0` no longer + clears+saves an empty folders map mid-rebuild (Sql/Mongo `_resetDeviceState`); + the live hierarchy is reconciled via `reconcileFolders()` after export. + `getFolderUidToBackendIdMap()` prefers `foldermap`; Collections resolve UIDs + via foldermap fallback when the folders cache entry is missing. Renames update + foldermap in place; deletes prune map entries. + + Deterministic UID generation (#81) remains as a safety net when the map is + empty (first sync / upgrade seed from `folders`). Opaque random UIDs for *new* + map entries can replace deterministic derivation in a follow-up once foldermap + has baked in production. + + **Optional follow-ups:** + + 1. Switch new map entries to opaque (random) UIDs once foldermap is ubiquitous. + 2. Stronger SyncCache CAS / partial-field save to reduce last-write-wins under + concurrent non-FolderSync writers. + 3. Fold into Horde 6 storage refactor — overlaps “Unify serverid vs backend + folder names” and “Fold SyncCache into device object”. + + **Key code paths:** `SyncCache.php` (`foldermap`, `reconcileFolders`, + `ensureFolderMap`), `State/{Base,Sql,Mongo}.php` (`getFolderUidToBackendIdMap`, + `_resetDeviceState`), `Collections.php`, `Request/FolderSync.php`. + - Pass `FILTERTYPE_*` to the driver by constant, not precomputed cutoff timestamps (supersedes the near-term `INCOMPLETETASKS` fix style). - Split `getMessage()` into per-class methods with shared base logic. @@ -330,8 +312,9 @@ active backlog; kept here so this file does not resurrect settled work. ### Stability (3.0.0-RC1 and related) -- Deterministic EAS folder UIDs on cache rebuild (#81, 2026-07-09); see - “Folder UID map vs volatile folder cache” for the remaining structural work. +- Deterministic EAS folder UIDs on cache rebuild (#81, 2026-07-09). +- Persistent SyncCache `foldermap` + FolderSync reconcile (see + “Folder UID map vs volatile folder cache”). - `FILTERTYPE_INCOMPLETETASKS`: pass FilterType to the driver; incomplete-only task sync in `horde/core` / `horde/nag`; no longer encode filter `8` as a Unix cutoff (avoids mail/calendar misconfiguration). diff --git a/lib/Horde/ActiveSync/Collections.php b/lib/Horde/ActiveSync/Collections.php index 10b6dfb7..ec4d177f 100644 --- a/lib/Horde/ActiveSync/Collections.php +++ b/lib/Horde/ActiveSync/Collections.php @@ -374,10 +374,24 @@ public function getBackendIdForFolderUid($folderid) $folder = $this->_cache->getFolder($folderid); if ($folder) { return $folder['serverid']; - } else { - $this->_logger->err('COLLECTIONS: Horde_ActiveSync_Collections::getBackendIdForFolderUid failed because folder was not found in cache.'); - throw new Horde_ActiveSync_Exception_FolderGone('Folder not found in cache.'); } + + // Fall back to the persistent foldermap. FolderSync synckey=0 used to + // clear+save an empty folders cache, leaving concurrent SYNC/PING + // unable to resolve known UIDs. foldermap survives that window. + $map = $this->_as->state->getFolderUidToBackendIdMap(); + $backendId = array_search($folderid, $map, true); + if ($backendId !== false) { + $this->_logger->meta(sprintf( + 'COLLECTIONS: Resolved folder uid %s via persistent foldermap to %s', + $folderid, + $backendId + )); + return $backendId; + } + + $this->_logger->err('COLLECTIONS: Horde_ActiveSync_Collections::getBackendIdForFolderUid failed because folder was not found in cache.'); + throw new Horde_ActiveSync_Exception_FolderGone('Folder not found in cache.'); } /** @@ -737,6 +751,9 @@ public function updateFolderinHierarchy( $update = false ) { $this->_cache->updateFolder($folder); + if (!empty($folder->_serverid) && !empty($folder->serverid)) { + $this->_as->state->rememberFolderUid($folder->_serverid, $folder->serverid); + } $cols = $this->_cache->getCollections(false); $cols[$folder->serverid]['serverid'] = $folder->_serverid; $this->_cache->updateCollection($cols[$folder->serverid]); @@ -753,12 +770,29 @@ public function updateFolderinHierarchy( public function deleteFolderFromHierarchy($uid) { $this->_cache->deleteFolder($uid); + $this->_as->state->resetFolderUidMap(); $this->_as->state->removeState([ 'id' => $uid, 'devId' => $this->_as->device->id, 'user' => $this->_as->device->user]); } + /** + * After FolderSync synckey=0, drop folders that are no longer live. + * + * Folders are intentionally kept during the reset window; reconcile once + * the new hierarchy UIDs are known. + * + * @param array $liveFolderUids EAS folder uids present after export. + * + * @since 3.0.3 + */ + public function reconcileHierarchyFolders(array $liveFolderUids) + { + $this->_cache->reconcileFolders($liveFolderUids); + $this->_as->state->resetFolderUidMap(); + } + /** * Return all know hierarchy changes. * diff --git a/lib/Horde/ActiveSync/Request/FolderSync.php b/lib/Horde/ActiveSync/Request/FolderSync.php index 72838868..e5826a9e 100644 --- a/lib/Horde/ActiveSync/Request/FolderSync.php +++ b/lib/Horde/ActiveSync/Request/FolderSync.php @@ -88,6 +88,7 @@ protected function _handle() $this->_handleError(); return true; } + $initialHierarchySync = empty($synckey) || $synckey === '0'; // Prepare the collections handler. $collections = $this->_activeSync->getCollectionsObject(); @@ -241,6 +242,13 @@ protected function _handle() } $this->_cleanUpAfterPairing(); + // Folders were kept across synckey=0 reset to avoid an empty-map race; + // drop any that are no longer in the live hierarchy. Skip when the live + // set is empty so a transient empty getFolderList() cannot wipe the map. + if ($initialHierarchySync && !empty($seenfolders)) { + $collections->reconcileHierarchyFolders($seenfolders); + } + $collections->save(); return true; diff --git a/lib/Horde/ActiveSync/State/Base.php b/lib/Horde/ActiveSync/State/Base.php index 5235d347..4e807b25 100644 --- a/lib/Horde/ActiveSync/State/Base.php +++ b/lib/Horde/ActiveSync/State/Base.php @@ -485,6 +485,10 @@ public function getKnownFolders() /** * Return the mapping of folder uids to backend folderids. * + * Prefers the persistent SyncCache foldermap (survives clearFolders / + * FolderSync synckey=0). Falls back to deriving the map from the folders + * cache for caches that predate foldermap. + * * @return array An array of backend folderids -> uids. * @since 2.9.0 */ @@ -495,16 +499,60 @@ public function getFolderUidToBackendIdMap() $cache = $this->getSyncCache( $this->_deviceInfo->id, $this->_deviceInfo->user, - ['folders'] + ['foldermap', 'folders'] ); - foreach ($cache['folders'] as $id => $folder) { - $this->_folderUidMap[$folder['serverid']] = $id; + if (!empty($cache['foldermap']) && is_array($cache['foldermap'])) { + $this->_folderUidMap = $cache['foldermap']; + } elseif (!empty($cache['folders']) && is_array($cache['folders'])) { + foreach ($cache['folders'] as $id => $folder) { + if (!empty($folder['serverid'])) { + $this->_folderUidMap[$folder['serverid']] = $id; + } + } } } return $this->_folderUidMap; } + /** + * Remember a backend-id → EAS-uid assignment for this request. + * + * Keeps the in-memory map coherent while FolderSync rebuilds folders. + * Persistence happens via SyncCache::updateFolder() / setFolderMapEntry(). + * + * @param string $backendId Backend folder id. + * @param string $uid EAS folder uid. + * + * @since 3.0.3 + */ + public function rememberFolderUid($backendId, $uid) + { + if ($backendId === '' || $backendId === null || $uid === '' || $uid === null) { + return; + } + if (!isset($this->_folderUidMap)) { + $this->getFolderUidToBackendIdMap(); + } + foreach ($this->_folderUidMap as $existingBackendId => $existingUid) { + if ($existingUid === $uid && $existingBackendId !== $backendId) { + unset($this->_folderUidMap[$existingBackendId]); + } + } + $this->_folderUidMap[$backendId] = $uid; + } + + /** + * Drop the request-local folder UID map so the next lookup reloads + * from SyncCache storage. + * + * @since 3.0.3 + */ + public function resetFolderUidMap() + { + unset($this->_folderUidMap); + } + /** * Get a EAS Folder Uid for the given backend server id. * diff --git a/lib/Horde/ActiveSync/State/Mongo.php b/lib/Horde/ActiveSync/State/Mongo.php index 90bfda10..f8e00fb1 100644 --- a/lib/Horde/ActiveSync/State/Mongo.php +++ b/lib/Horde/ActiveSync/State/Mongo.php @@ -1474,12 +1474,21 @@ protected function _resetDeviceState($id) if ($id != Horde_ActiveSync::REQUEST_TYPE_FOLDERSYNC) { $cache->removeCollection($id, false); } else { - $this->_logger->notice(' Clearing foldersync state from synccache.'); - $cache->clearFolders(); + // Do not clearFolders() here. Clearing and saving an empty folders + // map opens a race where concurrent SYNC/PING see FolderGone for + // every collection until this FolderSync finishes rebuilding. + // Hierarchy diff uses State::_folder (reset separately); SyncCache + // folders + foldermap stay available and are reconciled to the live + // set at the end of FolderSync (synckey=0). + // + // @author Torben Dannhauer + $this->_logger->notice(' Clearing foldersync collections/hierarchy from synccache (keeping folders + foldermap).'); + $cache->ensureFolderMap(); $cache->clearCollections(); $cache->hierarchy = '0'; } $cache->save(); + $this->resetFolderUidMap(); } /** @@ -1772,6 +1781,7 @@ public function getSyncCache($devid, $user, ?array $fields = null) 'wait' => false, 'hbinterval' => false, 'folders' => [], + 'foldermap' => [], 'hierarchy' => false, 'collections' => [], 'pingheartbeat' => false]; diff --git a/lib/Horde/ActiveSync/State/Sql.php b/lib/Horde/ActiveSync/State/Sql.php index 2c639671..d41369eb 100644 --- a/lib/Horde/ActiveSync/State/Sql.php +++ b/lib/Horde/ActiveSync/State/Sql.php @@ -1757,6 +1757,7 @@ public function getSyncCache($devid, $user, ?array $fields = null) 'wait' => false, 'hbinterval' => false, 'folders' => [], + 'foldermap' => [], 'hierarchy' => false, 'collections' => [], 'pingheartbeat' => false, @@ -2172,12 +2173,21 @@ protected function _resetDeviceState($id) if ($id != Horde_ActiveSync::REQUEST_TYPE_FOLDERSYNC) { $cache->removeCollection($id, false); } else { - $this->_logger->notice('STATE: Clearing foldersync state from synccache.'); - $cache->clearFolders(); + // Do not clearFolders() here. Clearing and saving an empty folders + // map opens a race where concurrent SYNC/PING see FolderGone for + // every collection until this FolderSync finishes rebuilding. + // Hierarchy diff uses State::_folder (reset separately); SyncCache + // folders + foldermap stay available and are reconciled to the live + // set at the end of FolderSync (synckey=0). + // + // @author Torben Dannhauer + $this->_logger->notice('STATE: Clearing foldersync collections/hierarchy from synccache (keeping folders + foldermap).'); + $cache->ensureFolderMap(); $cache->clearCollections(); $cache->hierarchy = '0'; } $cache->save(); + $this->resetFolderUidMap(); } /** diff --git a/lib/Horde/ActiveSync/SyncCache.php b/lib/Horde/ActiveSync/SyncCache.php index 6da987f8..65cbafed 100644 --- a/lib/Horde/ActiveSync/SyncCache.php +++ b/lib/Horde/ActiveSync/SyncCache.php @@ -27,6 +27,10 @@ * @property array $folders The folders cache: the list of * current folders, keyed by their internal uid and containing 'class', * 'serverid' and 'type'. + * @property array $foldermap Persistent backend-id → EAS-uid map. + * Survives clearFolders() / FolderSync synckey=0 so concurrent SYNC/PING + * never observe an empty UID map mid-rebuild. Pruned only when a folder + * is deleted or the hierarchy is reconciled to the live set. * @property integer $hbinterval The heartbeat interval (in seconds). * @property integer $wait The wait interval (in minutes). * @property integer $pingheartbeat The heartbeat used in PING requests. @@ -120,6 +124,7 @@ public function __construct( : Horde_ActiveSync::_wrapLogger($logger); $this->_logger->meta('Creating new Horde_ActiveSync_SyncCache.'); + $this->ensureFolderMap(); } public function __get($property) @@ -160,7 +165,8 @@ protected function _isValidProperty($property) { return in_array($property, [ 'hbinterval', 'wait', 'hierarchy', 'confirmed_synckeys', 'timestamp', - 'lasthbsyncstarted', 'lastsyncendnormal', 'folders', 'pingheartbeat']); + 'lasthbsyncstarted', 'lastsyncendnormal', 'folders', 'foldermap', + 'pingheartbeat']); } /** @@ -708,7 +714,10 @@ public function getFolders() } /** - * Clear the folder cache + * Clear the folder cache. + * + * Does not clear foldermap — UID assignments must survive FolderSync + * synckey=0 rebuilds so concurrent requests keep stable identities. */ public function clearFolders() { @@ -716,6 +725,100 @@ public function clearFolders() $this->_dirty['folders'] = true; } + /** + * Return the persistent backend-id → EAS-uid map. + * + * @return array + * @since 3.0.3 + */ + public function getFolderMap() + { + if (empty($this->_data['foldermap']) || !is_array($this->_data['foldermap'])) { + return []; + } + + return $this->_data['foldermap']; + } + + /** + * Seed foldermap from the folders cache when missing (upgrade path). + * + * @since 3.0.3 + */ + public function ensureFolderMap() + { + if (!isset($this->_data['foldermap']) || !is_array($this->_data['foldermap'])) { + $this->_data['foldermap'] = []; + } + if (!empty($this->_data['foldermap'])) { + return; + } + if (empty($this->_data['folders']) || !is_array($this->_data['folders'])) { + return; + } + foreach ($this->_data['folders'] as $uid => $folder) { + if (!empty($folder['serverid'])) { + $this->_data['foldermap'][$folder['serverid']] = $uid; + } + } + if (!empty($this->_data['foldermap'])) { + $this->_dirty['foldermap'] = true; + } + } + + /** + * Record or refresh a backend-id → EAS-uid assignment. + * + * @param string $backendId Backend folder id (e.g. INBOX). + * @param string $uid EAS folder uid. + * + * @since 3.0.3 + */ + public function setFolderMapEntry($backendId, $uid) + { + if ($backendId === '' || $backendId === null || $uid === '' || $uid === null) { + return; + } + if (!isset($this->_data['foldermap']) || !is_array($this->_data['foldermap'])) { + $this->_data['foldermap'] = []; + } + // Drop stale backend keys that previously pointed at this uid (rename). + foreach ($this->_data['foldermap'] as $existingBackendId => $existingUid) { + if ($existingUid === $uid && $existingBackendId !== $backendId) { + unset($this->_data['foldermap'][$existingBackendId]); + } + } + $this->_data['foldermap'][$backendId] = $uid; + $this->_dirty['foldermap'] = true; + } + + /** + * Remove folders (and foldermap entries) not in the live UID set. + * + * Used after FolderSync synckey=0 when folders were intentionally left + * in place during rebuild to avoid an empty-map race. + * + * @param array $liveFolderUids EAS folder uids that still exist. + * + * @since 3.0.3 + */ + public function reconcileFolders(array $liveFolderUids) + { + $live = array_flip($liveFolderUids); + foreach (array_keys($this->getFolders()) as $uid) { + if (!isset($live[$uid])) { + $this->deleteFolder($uid); + } + } + // Also drop foldermap orphans whose uid is not live. + foreach ($this->getFolderMap() as $backendId => $uid) { + if (!isset($live[$uid])) { + unset($this->_data['foldermap'][$backendId]); + $this->_dirty['foldermap'] = true; + } + } + } + /** * Refresh the folder cache from the backend. * @@ -726,6 +829,12 @@ public function refreshFolderCache() $cache = $this->_state->getSyncCache($this->_devid, $this->_user); $this->_data['folders'] = $cache['folders']; $this->_dirty['folders'] = false; + if (isset($cache['foldermap']) && is_array($cache['foldermap'])) { + $this->_data['foldermap'] = $cache['foldermap']; + $this->_dirty['foldermap'] = false; + } else { + $this->ensureFolderMap(); + } } /** @@ -759,6 +868,9 @@ public function updateFolder(Horde_ActiveSync_Message_Folder $folder) $this->_data['folders'][$folder->serverid]['type'] = $folder->type; $this->_dirty['folders'] = true; + if (!empty($folder->_serverid)) { + $this->setFolderMapEntry($folder->_serverid, $folder->serverid); + } } /** @@ -768,10 +880,29 @@ public function updateFolder(Horde_ActiveSync_Message_Folder $folder) */ public function deleteFolder($folder) { + $backendId = null; + if (!empty($this->_data['folders'][$folder]['serverid'])) { + $backendId = $this->_data['folders'][$folder]['serverid']; + } unset($this->_data['folders'][$folder]); unset($this->_data['collections'][$folder]); $this->_dirty['folders'] = true; $this->_markCollectionsDirty($folder); + + if ($backendId !== null + && isset($this->_data['foldermap'][$backendId]) + && $this->_data['foldermap'][$backendId] === $folder) { + unset($this->_data['foldermap'][$backendId]); + $this->_dirty['foldermap'] = true; + } elseif (!empty($this->_data['foldermap']) && is_array($this->_data['foldermap'])) { + foreach ($this->_data['foldermap'] as $mapBackendId => $uid) { + if ($uid === $folder) { + unset($this->_data['foldermap'][$mapBackendId]); + $this->_dirty['foldermap'] = true; + break; + } + } + } } /** diff --git a/test/unit/Horde/ActiveSync/FolderUidMapPersistenceTest.php b/test/unit/Horde/ActiveSync/FolderUidMapPersistenceTest.php new file mode 100644 index 00000000..bf58b013 --- /dev/null +++ b/test/unit/Horde/ActiveSync/FolderUidMapPersistenceTest.php @@ -0,0 +1,225 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync; + +use Horde_ActiveSync; +use Horde_ActiveSync_Collections; +use Horde_ActiveSync_Log_Logger; +use Horde_ActiveSync_Message_Folder; +use Horde_ActiveSync_State_Base; +use Horde_ActiveSync_SyncCache; +use Horde_Log_Handler_Null; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\TestCase; + +#[CoversNothing] +class FolderUidMapPersistenceTest extends TestCase +{ + protected function _newCache(array $stored = []): array + { + $stored = array_merge([ + 'confirmed_synckeys' => [], + 'lasthbsyncstarted' => false, + 'lastsyncendnormal' => false, + 'timestamp' => time(), + 'wait' => false, + 'hbinterval' => false, + 'folders' => [], + 'foldermap' => [], + 'hierarchy' => false, + 'collections' => [], + 'pingheartbeat' => false, + ], $stored); + + $state = $this->createMock(Horde_ActiveSync_State_Base::class); + $state->method('getSyncCache')->willReturn($stored); + + $cache = new Horde_ActiveSync_SyncCache( + $state, + 'device', + 'user', + new Horde_ActiveSync_Log_Logger(new Horde_Log_Handler_Null()) + ); + + return [$cache, $state]; + } + + public function testClearFoldersPreservesFolderMap() + { + [$cache] = $this->_newCache([ + 'folders' => [ + 'Faaaaaaaa' => [ + 'class' => 'Email', + 'serverid' => 'INBOX', + 'type' => Horde_ActiveSync::FOLDER_TYPE_INBOX, + ], + ], + 'foldermap' => [ + 'INBOX' => 'Faaaaaaaa', + ], + ]); + + $cache->clearFolders(); + + $this->assertSame([], $cache->getFolders()); + $this->assertSame(['INBOX' => 'Faaaaaaaa'], $cache->getFolderMap()); + } + + public function testEnsureFolderMapSeedsFromFolders() + { + [$cache] = $this->_newCache([ + 'folders' => [ + 'Fbbbbbbbb' => [ + 'class' => 'Email', + 'serverid' => 'INBOX/Sent', + 'type' => Horde_ActiveSync::FOLDER_TYPE_USER_MAIL, + ], + ], + 'foldermap' => [], + ]); + + $this->assertSame( + ['INBOX/Sent' => 'Fbbbbbbbb'], + $cache->getFolderMap() + ); + } + + public function testUpdateFolderMaintainsFolderMapAndRename() + { + [$cache] = $this->_newCache([ + 'foldermap' => [ + 'INBOX/Old' => 'Fcccccccc', + ], + ]); + + $folder = new Horde_ActiveSync_Message_Folder(['protocolversion' => Horde_ActiveSync::VERSION_FOURTEEN]); + $folder->serverid = 'Fcccccccc'; + $folder->_serverid = 'INBOX/New'; + $folder->type = Horde_ActiveSync::FOLDER_TYPE_USER_MAIL; + $folder->displayname = 'New'; + $folder->parentid = '0'; + + $cache->updateFolder($folder); + + $this->assertSame('INBOX/New', $cache->getFolders()['Fcccccccc']['serverid']); + $this->assertSame(['INBOX/New' => 'Fcccccccc'], $cache->getFolderMap()); + $this->assertArrayNotHasKey('INBOX/Old', $cache->getFolderMap()); + } + + public function testDeleteFolderRemovesFolderMapEntry() + { + [$cache] = $this->_newCache([ + 'folders' => [ + 'Fdddddddd' => [ + 'class' => 'Email', + 'serverid' => 'INBOX/Gone', + 'type' => Horde_ActiveSync::FOLDER_TYPE_USER_MAIL, + ], + ], + 'foldermap' => [ + 'INBOX/Gone' => 'Fdddddddd', + 'INBOX' => 'Feeeeeeee', + ], + ]); + + $cache->deleteFolder('Fdddddddd'); + + $this->assertArrayNotHasKey('Fdddddddd', $cache->getFolders()); + $this->assertSame(['INBOX' => 'Feeeeeeee'], $cache->getFolderMap()); + } + + public function testReconcileFoldersDropsStaleEntries() + { + [$cache] = $this->_newCache([ + 'folders' => [ + 'Flive0001' => [ + 'class' => 'Email', + 'serverid' => 'INBOX', + 'type' => Horde_ActiveSync::FOLDER_TYPE_INBOX, + ], + 'Fstale001' => [ + 'class' => 'Email', + 'serverid' => 'INBOX/Stale', + 'type' => Horde_ActiveSync::FOLDER_TYPE_USER_MAIL, + ], + ], + 'foldermap' => [ + 'INBOX' => 'Flive0001', + 'INBOX/Stale' => 'Fstale001', + ], + ]); + + $cache->reconcileFolders(['Flive0001']); + + $this->assertSame(['Flive0001'], array_keys($cache->getFolders())); + $this->assertSame(['INBOX' => 'Flive0001'], $cache->getFolderMap()); + } + + public function testGetBackendIdFallsBackToFolderMapWhenFoldersEmpty() + { + $state = $this->createMock(Horde_ActiveSync_State_Base::class); + $state->method('getFolderUidToBackendIdMap')->willReturn([ + 'INBOX' => 'Fpersist1', + ]); + + $cache = $this->createMock(Horde_ActiveSync_SyncCache::class); + $cache->method('getFolder')->willReturn(false); + + $as = $this->createMock(Horde_ActiveSync::class); + $as->state = $state; + $as->logger = new Horde_ActiveSync_Log_Logger(new Horde_Log_Handler_Null()); + + $collections = new Horde_ActiveSync_Collections($cache, $as); + + $this->assertSame( + 'INBOX', + $collections->getBackendIdForFolderUid('Fpersist1') + ); + } + + public function testStateMapPrefersPersistedFolderMap() + { + $state = $this->getMockBuilder(Horde_ActiveSync_State_Base::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $state->expects($this->once()) + ->method('getSyncCache') + ->willReturn([ + 'foldermap' => ['INBOX' => 'Ffrommap0'], + 'folders' => [ + 'Folduid01' => [ + 'class' => 'Email', + 'serverid' => 'INBOX', + 'type' => 2, + ], + ], + ]); + + $device = new \stdClass(); + $device->id = 'device'; + $device->user = 'user'; + $prop = new \ReflectionProperty(Horde_ActiveSync_State_Base::class, '_deviceInfo'); + $prop->setAccessible(true); + $prop->setValue($state, $device); + + $this->assertSame( + ['INBOX' => 'Ffrommap0'], + $state->getFolderUidToBackendIdMap() + ); + } +}