1<?php 2 3namespace dokuwiki\test\Search\Collection; 4 5use dokuwiki\Search\Collection\PageMetaCollection; 6use dokuwiki\Search\Exception\IndexIntegrityException; 7use dokuwiki\Search\Exception\IndexLockException; 8use dokuwiki\Search\Index\FileIndex; 9use dokuwiki\Search\Index\Lock; 10use dokuwiki\Search\Index\MemoryIndex; 11 12class LookupCollectionTest extends \DokuWikiTest 13{ 14 protected function tearDown(): void 15 { 16 Lock::releaseAll(); 17 parent::tearDown(); 18 } 19 20 /** 21 * A shared, caller-owned index lock must survive a collection lock()/unlock() cycle 22 * 23 * Indexer::addPage() locks the page index once, then hands it to a series of 24 * collections that each lock() and unlock() around their work. The collection must 25 * treat that shared, already-locked index as owned by the caller and leave its lock 26 * untouched - previously the collection's unlock() released it, dropping the caller's 27 * lock (and the filesystem lock) mid-operation. 28 */ 29 public function testSharedIndexLockSurvivesCollectionCycle() 30 { 31 global $conf; 32 // a dedicated shared index name so this mutating test cannot disturb the RIDs 33 // the other tests rely on in this class's shared data dir 34 $lockDir = $conf['lockdir'] . '/sharedlockentity.index'; 35 36 // caller acquires the shared entity index lock, as Indexer::addPage() does 37 // with the page index 38 $sharedIndex = new FileIndex('sharedlockentity', '', true); 39 $this->assertTrue($sharedIndex->isWritable()); 40 $this->assertDirectoryExists($lockDir); 41 42 // several collections work on the shared index in turn 43 foreach (['sharedlock_one', 'sharedlock_two'] as $key) { 44 (new PageMetaCollection($key, $sharedIndex))->lock() 45 ->addEntity('sharedlock:start', ['wiki:a', 'wiki:b'])->unlock(); 46 47 // the caller's lock on the shared index must still be held 48 $this->assertTrue($sharedIndex->isWritable(), "shared lock dropped after $key"); 49 $this->assertDirectoryExists($lockDir, "filesystem lock dropped after $key"); 50 } 51 52 // releasing the caller's own lock finally drops it 53 $sharedIndex->unlock(); 54 $this->assertFalse($sharedIndex->isWritable()); 55 $this->assertDirectoryDoesNotExist($lockDir); 56 } 57 /** 58 * Add data and directly check the underlying indexes for correctness 59 */ 60 public function testAddEntity() 61 { 62 $index = new MockLookupCollection('a_entity', 'a_token', 'a_freq', 'a_reverse'); 63 $index->lock(); 64 $index->addEntity('wiki:start', ['wiki:logo.png', 'wiki:banner.jpg', 'wiki:icon.svg']); 65 $index->unlock(); 66 67 // check entity index 68 $idxEntity = new MemoryIndex('a_entity'); 69 $this->assertEquals('wiki:start', $idxEntity->retrieveRow(0)); 70 71 // check token index (single file, no suffix) 72 $idxToken = new MemoryIndex('a_token'); 73 $this->assertEquals('wiki:logo.png', $idxToken->retrieveRow(0)); 74 $this->assertEquals('wiki:banner.jpg', $idxToken->retrieveRow(1)); 75 $this->assertEquals('wiki:icon.svg', $idxToken->retrieveRow(2)); 76 77 // check frequency index — all frequencies are 1 (written without *1) 78 $idxFreq = new MemoryIndex('a_freq'); 79 $this->assertEquals('0', $idxFreq->retrieveRow(0)); // entity 0 with implicit freq 1 80 $this->assertEquals('0', $idxFreq->retrieveRow(1)); 81 $this->assertEquals('0', $idxFreq->retrieveRow(2)); 82 83 // check reverse index 84 $idxRev = new MemoryIndex('a_reverse'); 85 $this->assertEquals('0:1:2', $idxRev->retrieveRow(0)); 86 } 87 88 /** 89 * Duplicate tokens should be deduplicated 90 */ 91 public function testAddEntityDedup() 92 { 93 $index = new MockLookupCollection('b_entity', 'b_token', 'b_freq', 'b_reverse'); 94 $index->lock(); 95 $index->addEntity('wiki:start', ['wiki:logo.png', 'wiki:logo.png', 'wiki:banner.jpg']); 96 $index->unlock(); 97 98 $idxToken = new MemoryIndex('b_token'); 99 $this->assertEquals('wiki:logo.png', $idxToken->retrieveRow(0)); 100 $this->assertEquals('wiki:banner.jpg', $idxToken->retrieveRow(1)); 101 102 $idxRev = new MemoryIndex('b_reverse'); 103 $this->assertEquals('0:1', $idxRev->retrieveRow(0)); 104 } 105 106 /** 107 * Updating an entity should remove old tokens and add new ones 108 */ 109 public function testUpdateEntity() 110 { 111 $index = new MockLookupCollection('c_entity', 'c_token', 'c_freq', 'c_reverse'); 112 113 // initial add 114 $index->lock(); 115 $index->addEntity('wiki:start', ['wiki:logo.png', 'wiki:banner.jpg']); 116 $index->unlock(); 117 118 // update: remove logo, keep banner, add icon 119 $index->lock(); 120 $index->addEntity('wiki:start', ['wiki:banner.jpg', 'wiki:icon.svg']); 121 $index->unlock(); 122 123 // logo should be removed from frequency index 124 $idxFreq = new MemoryIndex('c_freq'); 125 $this->assertEquals('', $idxFreq->retrieveRow(0)); // logo removed 126 $this->assertEquals('0', $idxFreq->retrieveRow(1)); // banner still on entity 0 127 $this->assertEquals('0', $idxFreq->retrieveRow(2)); // icon added on entity 0 128 129 // reverse index should only have banner and icon 130 $idxRev = new MemoryIndex('c_reverse'); 131 $this->assertEquals('1:2', $idxRev->retrieveRow(0)); 132 } 133 134 /** 135 * Test reverse assignments returns two-level structure with empty group key 136 */ 137 public function testReverseAssignments() 138 { 139 $index = new MockLookupCollection('d_entity', 'd_token', 'd_freq', 'd_reverse'); 140 $index->lock(); 141 $index->addEntity('wiki:start', ['wiki:logo.png', 'wiki:banner.jpg']); 142 $index->unlock(); 143 144 $result = $index->getReverseAssignments('wiki:start'); 145 $this->assertEquals([0 => [0 => 0, 1 => 0]], $result); 146 } 147 148 /** 149 * Adding entity without lock should throw exception 150 */ 151 public function testAddEntityWithoutLock() 152 { 153 $this->expectException(IndexLockException::class); 154 155 $index = new MockLookupCollection(); 156 $index->addEntity('wiki:start', ['wiki:logo.png']); 157 } 158 159 /** 160 * Adding empty token list should clear entity from indexes 161 */ 162 public function testEmptyTokens() 163 { 164 $index = new MockLookupCollection('f_entity', 'f_token', 'f_freq', 'f_reverse'); 165 166 // add some tokens first 167 $index->lock(); 168 $index->addEntity('wiki:start', ['wiki:logo.png']); 169 $index->unlock(); 170 171 // now clear 172 $index->lock(); 173 $index->addEntity('wiki:start', []); 174 $index->unlock(); 175 176 // frequency index should be empty for this token 177 $idxFreq = new MemoryIndex('f_freq'); 178 $this->assertEquals('', $idxFreq->retrieveRow(0)); 179 180 // reverse index should be empty 181 $idxRev = new MemoryIndex('f_reverse'); 182 $this->assertEquals('', $idxRev->retrieveRow(0)); 183 } 184 185 /** 186 * Test that PageMetaCollection('relation_media') uses correct index names 187 */ 188 public function testMediaCollection() 189 { 190 $index = new PageMetaCollection('relation_media'); 191 $index->lock(); 192 $index->addEntity('wiki:start', ['wiki:logo.png', 'wiki:banner.jpg']); 193 $index->unlock(); 194 195 $idxToken = new MemoryIndex('relation_media_w'); 196 $this->assertEquals('wiki:logo.png', $idxToken->retrieveRow(0)); 197 $this->assertEquals('wiki:banner.jpg', $idxToken->retrieveRow(1)); 198 199 $idxRev = new MemoryIndex('relation_media_p'); 200 $this->assertEquals('0:1', $idxRev->retrieveRow(0)); 201 } 202 203 /** 204 * Test that PageMetaCollection('relation_references') uses correct index names 205 */ 206 public function testReferencesCollection() 207 { 208 $index = new PageMetaCollection('relation_references'); 209 $index->lock(); 210 $index->addEntity('wiki:start', ['wiki:syntax', 'wiki:welcome']); 211 $index->unlock(); 212 213 $idxToken = new MemoryIndex('relation_references_w'); 214 $this->assertEquals('wiki:syntax', $idxToken->retrieveRow(0)); 215 $this->assertEquals('wiki:welcome', $idxToken->retrieveRow(1)); 216 217 $idxRev = new MemoryIndex('relation_references_p'); 218 $this->assertEquals('0:1', $idxRev->retrieveRow(0)); 219 220 $result = $index->getReverseAssignments('wiki:start'); 221 $this->assertEquals([0 => [0 => 0, 1 => 0]], $result); 222 } 223 224 /** 225 * resolveTokens should deduplicate and assign frequency 1 under group 0 226 */ 227 public function testResolveTokens() 228 { 229 $index = new MockLookupCollection('rt_entity', 'rt_token', 'rt_freq', 'rt_reverse'); 230 $index->lock(); 231 232 $result = $this->callInaccessibleMethod($index, 'resolveTokens', [ 233 ['wiki:logo.png', 'wiki:banner.jpg', 'wiki:logo.png'], 234 ]); 235 236 // all tokens under group 0 (non-split) 237 $this->assertArrayHasKey(0, $result); 238 $this->assertCount(2, $result[0]); // deduplicated 239 240 // token IDs are sequential: logo=0, banner=1 241 $this->assertEquals(1, $result[0][0]); // logo freq=1 242 $this->assertEquals(1, $result[0][1]); // banner freq=1 243 } 244 245 /** 246 * resolveTokens with empty input should return empty array 247 */ 248 public function testResolveTokensEmpty() 249 { 250 $index = new MockLookupCollection('rte_entity', 'rte_token', 'rte_freq', 'rte_reverse'); 251 $index->lock(); 252 253 $result = $this->callInaccessibleMethod($index, 'resolveTokens', [[]]); 254 255 $this->assertEmpty($result); 256 } 257 258 /** 259 * countTokens should deduplicate and assign frequency 1 260 */ 261 public function testCountTokens() 262 { 263 $index = new MockLookupCollection(); 264 265 $result = $this->callInaccessibleMethod($index, 'countTokens', [ 266 ['wiki:logo.png', 'wiki:banner.jpg', 'wiki:logo.png'], 267 ]); 268 269 $this->assertEquals([ 270 'wiki:logo.png' => 1, 271 'wiki:banner.jpg' => 1, 272 ], $result); 273 } 274 275 /** 276 * getEntitiesWithData returns entities that have frequency data 277 */ 278 public function testGetEntitiesWithData() 279 { 280 $index = new MockLookupCollection('ewd_entity', 'ewd_token', 'ewd_freq', 'ewd_reverse'); 281 $index->lock(); 282 $index->addEntity('wiki:start', ['wiki:syntax', 'wiki:welcome']); 283 $index->addEntity('wiki:other', ['wiki:syntax']); 284 $index->addEntity('wiki:empty', []); 285 $index->unlock(); 286 287 $result = $index->getEntitiesWithData(); 288 sort($result); 289 $this->assertEquals(['wiki:other', 'wiki:start'], $result); 290 } 291 292 /** 293 * resolveTokenFrequencies returns entity frequencies for given token IDs 294 */ 295 public function testResolveTokenFrequencies() 296 { 297 $index = new MockLookupCollection('rtf_entity', 'rtf_token', 'rtf_freq', 'rtf_reverse'); 298 $index->lock(); 299 $index->addEntity('wiki:start', ['wiki:syntax', 'wiki:welcome']); 300 $index->addEntity('wiki:other', ['wiki:syntax']); 301 $index->unlock(); 302 303 // token ID 0 = wiki:syntax, referenced by both entities 304 $result = $index->resolveTokenFrequencies(0, [0]); 305 $this->assertArrayHasKey(0, $result); 306 $this->assertCount(2, $result[0]); // two entities have this token 307 } 308 309 /** 310 * checkIntegrity passes on a healthy non-split collection 311 */ 312 public function testCheckIntegrityHealthy() 313 { 314 $index = new MockLookupCollection('cih_entity', 'cih_token', 'cih_freq', 'cih_reverse'); 315 $index->lock(); 316 $index->addEntity('wiki:start', ['wiki:syntax']); 317 $index->unlock(); 318 319 $index->checkIntegrity(); // should not throw 320 $this->assertTrue(true); 321 } 322 323 /** 324 * checkIntegrity passes on an empty non-split collection 325 */ 326 public function testCheckIntegrityEmpty() 327 { 328 $index = new MockLookupCollection('cie_entity', 'cie_token', 'cie_freq', 'cie_reverse'); 329 $index->checkIntegrity(); // should not throw 330 $this->assertTrue(true); 331 } 332 333 /** 334 * checkIntegrity detects token/frequency mismatch on non-split collection 335 */ 336 public function testCheckIntegrityTokenFreqMismatch() 337 { 338 global $conf; 339 $index = new MockLookupCollection('cim_entity', 'cim_token', 'cim_freq', 'cim_reverse'); 340 $index->lock(); 341 $index->addEntity('wiki:start', ['wiki:syntax']); 342 $index->unlock(); 343 344 // corrupt: add extra line to token index 345 file_put_contents($conf['indexdir'] . '/cim_token.idx', "extra\n", FILE_APPEND); 346 347 $this->expectException(IndexIntegrityException::class); 348 (new MockLookupCollection('cim_entity', 'cim_token', 'cim_freq', 'cim_reverse'))->checkIntegrity(); 349 } 350 351 /** 352 * checkIntegrity detects entity/reverse mismatch on non-split collection 353 */ 354 public function testCheckIntegrityEntityReverseMismatch() 355 { 356 global $conf; 357 $index = new MockLookupCollection('cir_entity', 'cir_token', 'cir_freq', 'cir_reverse'); 358 $index->lock(); 359 $index->addEntity('wiki:start', ['wiki:syntax']); 360 $index->unlock(); 361 362 // corrupt: add extra line to reverse index 363 file_put_contents($conf['indexdir'] . '/cir_reverse.idx', "0\n", FILE_APPEND); 364 365 $this->expectException(IndexIntegrityException::class); 366 (new MockLookupCollection('cir_entity', 'cir_token', 'cir_freq', 'cir_reverse'))->checkIntegrity(); 367 } 368 369 /** 370 * checkIntegrity detects missing frequency index when token index exists 371 */ 372 public function testCheckIntegrityMissingFreqIndex() 373 { 374 global $conf; 375 $index = new MockLookupCollection('cimf_entity', 'cimf_token', 'cimf_freq', 'cimf_reverse'); 376 $index->lock(); 377 $index->addEntity('wiki:start', ['wiki:syntax']); 378 $index->unlock(); 379 380 // corrupt: delete frequency index 381 @unlink($conf['indexdir'] . '/cimf_freq.idx'); 382 383 $this->expectException(IndexIntegrityException::class); 384 (new MockLookupCollection('cimf_entity', 'cimf_token', 'cimf_freq', 'cimf_reverse'))->checkIntegrity(); 385 } 386 387 /** 388 * groupToSuffix throws on non-0 group for non-split collection 389 */ 390 public function testGroupToSuffixValidation() 391 { 392 $this->expectException(\dokuwiki\Search\Exception\IndexUsageException::class); 393 394 $index = new MockLookupCollection('gs_entity', 'gs_token', 'gs_freq', 'gs_reverse'); 395 // non-split collection should reject group 5 396 $index->getTokenIndex(5); 397 } 398} 399