1<?php 2 3namespace dokuwiki\test\Search; 4 5use dokuwiki\Search\Indexer; 6use dokuwiki\Search\Exception\IndexLockException; 7use dokuwiki\Search\Index\FileIndex; 8use dokuwiki\Search\Index\Lock; 9use dokuwiki\Search\MetadataSearch; 10 11/** 12 * Tests the Indexer class 13 */ 14class IndexerTest extends \DokuWikiTest 15{ 16 /** 17 * Test basic page indexing via addPage 18 */ 19 public function testAddPage() 20 { 21 $indexer = new Indexer(); 22 23 saveWikiText('testpage', 'Foo bar baz.', 'Test initialization'); 24 $indexer->addPage('testpage'); 25 26 // page should be in the entity index 27 $pageIndex = new FileIndex('page'); 28 $result = $pageIndex->search('/^testpage$/'); 29 $this->assertNotEmpty($result, 'testpage not found in page.idx'); 30 } 31 32 /** 33 * Test that deletePage clears data 34 */ 35 public function testDeletePage() 36 { 37 $indexer = new Indexer(); 38 39 saveWikiText('delpage', 'Delete me content.', 'Test initialization'); 40 $indexer->addPage('delpage'); 41 $indexer->deletePage('delpage', true); 42 43 // page entity persists in page.idx but data is cleared 44 $pageIndex = new FileIndex('page'); 45 $result = $pageIndex->search('/^delpage$/'); 46 $this->assertNotEmpty($result, 'delpage should persist in page.idx'); 47 } 48 49 /** 50 * Test renamePage clears old and indexes new 51 */ 52 public function testRenamePage() 53 { 54 $indexer = new Indexer(); 55 56 saveWikiText('old_name', 'Old page content words.', 'Test initialization'); 57 $indexer->addPage('old_name'); 58 59 $indexer->renamePage('old_name', 'new_name'); 60 61 // the entity is renamed in place: new name present, old name gone 62 $pageIndex = new FileIndex('page'); 63 $this->assertNotEmpty($pageIndex->search('/^new_name$/'), 'new_name not found in page.idx after rename'); 64 $this->assertEmpty($pageIndex->search('/^old_name$/'), 'old_name should be gone from page.idx after rename'); 65 } 66 67 /** 68 * renamePage must preserve the renamed page's outgoing references 69 * 70 * The rename only changes the page's name in the index, not its content, so all of 71 * its index associations - including the pages it links to (relation_references) - 72 * must survive under the new name. This is what allows a page renamed early during a 73 * namespace move to still be found as a backlink source for pages moved afterwards. 74 * It must work even though the destination page is not on disk yet at rename time 75 * (the move operation writes it only later), so re-indexing from disk cannot be relied 76 * upon here. 77 * 78 * @see https://github.com/dokuwiki/dokuwiki - regression after the indexer rewrite 79 */ 80 public function testRenamePagePreservesOutgoingReferences() 81 { 82 $indexer = new Indexer(); 83 84 saveWikiText('refsource', '[[target:page]]', 'Test initialization'); 85 $indexer->addPage('refsource'); 86 87 $search = new MetadataSearch(); 88 89 // sanity: the source page references target:page 90 $value = 'target:page'; 91 $this->assertEquals(['refsource'], $search->lookupKey('relation_references', $value)); 92 93 // rename the source page WITHOUT writing the destination to disk first, 94 // mimicking how the move plugin calls renamePage before saving the new page 95 $indexer->renamePage('refsource', 'moved:newsource'); 96 97 // the outgoing reference must now belong to the renamed page 98 $value = 'target:page'; 99 $this->assertEquals( 100 ['moved:newsource'], 101 $search->lookupKey('relation_references', $value), 102 'rename lost the outgoing reference of the renamed page' 103 ); 104 } 105 106 /** 107 * renamePage onto a name that already has its own index entry 108 * 109 * The renamed page must take over the destination name (keeping its own data) while the 110 * destination's previous data is dropped. The stale destination row must be vacated so the 111 * name resolves only to the renamed entity and does not leak as a phantom page. 112 */ 113 public function testRenamePageOntoExistingPage() 114 { 115 $indexer = new Indexer(); 116 117 saveWikiText('src', '[[target:fromsrc]]', 'Test initialization'); 118 $indexer->addPage('src'); 119 saveWikiText('dst', '[[target:fromdst]]', 'Test initialization'); 120 $indexer->addPage('dst'); 121 122 $indexer->renamePage('src', 'dst'); 123 124 $search = new MetadataSearch(); 125 126 // dst now carries src's outgoing reference ... 127 $value = 'target:fromsrc'; 128 $this->assertEquals(['dst'], $search->lookupKey('relation_references', $value)); 129 // ... and the destination's previous reference is gone 130 $value = 'target:fromdst'; 131 $this->assertEquals([], $search->lookupKey('relation_references', $value)); 132 133 // exactly one entity named 'dst', the old name and any phantom entry are gone 134 $allPages = $indexer->getAllPages(); 135 $this->assertSame(['dst'], array_values(array_filter($allPages, fn($p) => $p === 'dst' || $p === 'src'))); 136 } 137 138 /** 139 * Test that clear removes all index files 140 */ 141 public function testClear() 142 { 143 global $conf; 144 $indexer = new Indexer(); 145 146 saveWikiText('clearpage', 'Some words to index.', 'Test initialization'); 147 $indexer->addPage('clearpage'); 148 149 $this->assertFileExists($conf['indexdir'] . '/page.idx'); 150 151 $indexer->clear(); 152 153 $this->assertFileDoesNotExist($conf['indexdir'] . '/page.idx'); 154 } 155 156 /** 157 * Test that getVersion returns a version string 158 */ 159 public function testGetVersion() 160 { 161 $indexer = new Indexer(); 162 // with no version-modifying plugins active the raw INDEXER_VERSION is returned 163 $this->assertSame(\dokuwiki\Search\INDEXER_VERSION, $indexer->getVersion()); 164 } 165 166 /** 167 * Test needsIndexing returns true for new pages 168 */ 169 public function testNeedsIndexing() 170 { 171 $indexer = new Indexer(); 172 173 saveWikiText('needsidx', 'Some content.', 'Test initialization'); 174 // a brand-new page has no .indexed tag yet, so it always needs indexing 175 $this->assertTrue($indexer->needsIndexing('needsidx')); 176 177 // once indexed it is up to date, even when saved and indexed in the same second 178 $indexer->addPage('needsidx'); 179 $this->assertFalse($indexer->needsIndexing('needsidx')); 180 $this->assertTrue($indexer->needsIndexing('needsidx', true)); // force 181 } 182 183 /** 184 * addPage returns true when it indexed the page and false when there was nothing to do 185 */ 186 public function testAddPageReturn() 187 { 188 $indexer = new Indexer(); 189 190 saveWikiText('retadd', 'Some content to index.', 'Test initialization'); 191 $this->assertTrue($indexer->addPage('retadd'), 'addPage should report work done'); 192 193 // already up to date: nothing to do 194 $this->assertFalse($indexer->addPage('retadd'), 'addPage should report nothing to do when up to date'); 195 196 // forcing reindexing always reports work done 197 $this->assertTrue($indexer->addPage('retadd', true), 'forced addPage should report work done'); 198 } 199 200 /** 201 * deletePage returns true when it removed the page and false when there was nothing to do 202 */ 203 public function testDeletePageReturn() 204 { 205 $indexer = new Indexer(); 206 207 // never indexed and not forced: nothing to do 208 $this->assertFalse($indexer->deletePage('retdel'), 'deletePage should report nothing to do for an unknown page'); 209 210 saveWikiText('retdel', 'Delete me content.', 'Test initialization'); 211 $indexer->addPage('retdel'); 212 $this->assertTrue($indexer->deletePage('retdel'), 'deletePage should report work done'); 213 214 // the delete removed the .indexed tag, so a second unforced call has nothing to do 215 $this->assertFalse($indexer->deletePage('retdel'), 'deletePage should report nothing to do once removed'); 216 } 217 218 /** 219 * renamePage returns true when it renamed the page and false for the no-op cases 220 */ 221 public function testRenamePageReturn() 222 { 223 $indexer = new Indexer(); 224 225 // identical names: nothing to do 226 $this->assertFalse($indexer->renamePage('retrename', 'retrename'), 'renamePage should report nothing to do for identical names'); 227 228 // old page not in the index: nothing to do 229 $this->assertFalse($indexer->renamePage('retrename', 'retrenamed'), 'renamePage should report nothing to do for an unindexed page'); 230 231 saveWikiText('retrename', 'Rename me content.', 'Test initialization'); 232 $indexer->addPage('retrename'); 233 $this->assertTrue($indexer->renamePage('retrename', 'retrenamed'), 'renamePage should report work done'); 234 } 235 236 /** 237 * Test the logger callback 238 */ 239 public function testLogger() 240 { 241 $messages = []; 242 $indexer = (new Indexer())->setLogger(function ($msg) use (&$messages) { 243 $messages[] = $msg; 244 }); 245 246 saveWikiText('logpage', 'Log test content.', 'Test initialization'); 247 $indexer->addPage('logpage'); 248 249 // second call detects the page is already up to date 250 $indexer->addPage('logpage'); 251 $this->assertNotEmpty($messages); 252 $this->assertStringContainsString('up to date', end($messages)); 253 } 254 255 /** 256 * updateMetadataRegistry merges new keys into the existing registry rather 257 * than overwriting it, and leaves no lock behind 258 */ 259 public function testUpdateMetadataRegistryMergesKeys() 260 { 261 global $conf; 262 $fn = $conf['indexdir'] . '/metadata.idx'; 263 $indexer = new Indexer(); 264 265 $indexer->updateMetadataRegistry(['alpha']); 266 $indexer->updateMetadataRegistry(['beta']); 267 268 $keys = file($fn, FILE_IGNORE_NEW_LINES); 269 $this->assertContains('alpha', $keys, 'first key lost when second was added'); 270 $this->assertContains('beta', $keys); 271 272 // the registry lock must not be held once the call returns 273 Lock::acquire('metadata'); 274 Lock::release('metadata'); 275 $this->assertFalse(is_dir($conf['lockdir'] . '/metadata.index')); 276 } 277 278 /** 279 * The registry read-modify-write is guarded by the 'metadata' lock, so a 280 * lock held by another process blocks the update instead of letting it 281 * clobber a concurrent writer's key 282 */ 283 public function testUpdateMetadataRegistryIsGuardedByLock() 284 { 285 global $conf; 286 $dir = $conf['lockdir'] . '/metadata.index'; 287 288 // simulate a foreign lock and don't wait for it in the test 289 mkdir($dir); 290 touch($dir); 291 self::setInaccessibleProperty(new Lock(), 'waitTimeout', 0); 292 293 try { 294 $this->expectException(IndexLockException::class); 295 (new Indexer())->updateMetadataRegistry(['newkey']); 296 } finally { 297 self::setInaccessibleProperty(new Lock(), 'waitTimeout', 3); 298 @rmdir($dir); 299 } 300 } 301} 302