xref: /dokuwiki/_test/tests/Search/IndexerTest.php (revision 846f53c19679a507af769cd0087abf10115ab902)
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