xref: /dokuwiki/_test/tests/ChangeLog/PageChangeLogTest.php (revision 63b227866ea0b85ebdda774ff73181e683443a0d)
1<?php
2
3namespace dokuwiki\test\ChangeLog;
4
5use dokuwiki\ChangeLog\PageChangeLog;
6
7/**
8 * Tests for dokuwiki\ChangeLog\PageChangeLog.
9 */
10class PageChangeLogTest extends \DokuWikiTest
11{
12    /**
13     * A page deleted through DokuWiki is recorded as its own revision, newer than the
14     * last revision that still had content. getRelativeRevision() must walk back from
15     * that deletion entry to the last content revision (issue #4635).
16     */
17    public function testRevisionBeforeNormalDeletion()
18    {
19        $page = 'changelog_deleted';
20        saveWikiText($page, 'first content', 'create', false);
21        $this->waitForTick(true);
22        saveWikiText($page, 'second content longer', 'edit', false);
23        $this->waitForTick(true);
24
25        $editRev = (new PageChangeLog($page))->currentRevision();
26
27        saveWikiText($page, '', 'delete', false);
28        clearstatcache();
29
30        $changelog = new PageChangeLog($page);
31        $delRev = $changelog->currentRevision();
32
33        $this->assertNotEquals($editRev, $delRev, 'deletion should get its own revision');
34        $this->assertEquals(
35            DOKU_CHANGE_TYPE_DELETE,
36            $changelog->getRevisionInfo($delRev)['type'],
37            'current revision should be the deletion'
38        );
39        $this->assertEquals(
40            $editRev,
41            $changelog->getRelativeRevision($delRev, -1),
42            'the revision before the deletion should be the last edit'
43        );
44    }
45
46    /**
47     * An external deletion is detected and persisted on first read as its own revision
48     * with an unknown exact date, newer than the last content revision.
49     * getRelativeRevision() must walk back from it to that last content revision
50     * (issue #4635).
51     */
52    public function testRevisionBeforeExternalDeletion()
53    {
54        $page = 'changelog_extdeleted';
55        saveWikiText($page, 'first content', 'create', false);
56        $this->waitForTick(true);
57        saveWikiText($page, 'second content longer', 'edit', false);
58        $this->waitForTick(true);
59
60        $editRev = (new PageChangeLog($page))->currentRevision();
61
62        // delete the page file externally, bypassing DokuWiki
63        unlink(wikiFN($page));
64        clearstatcache();
65
66        // first read detects and persists the external deletion
67        $changelog = new PageChangeLog($page);
68        $delRev = $changelog->currentRevision();
69        $delInfo = $changelog->getRevisionInfo($delRev);
70
71        $this->assertNotEquals($editRev, $delRev, 'external deletion should get its own revision');
72        $this->assertEquals(DOKU_CHANGE_TYPE_DELETE, $delInfo['type'], 'current revision should be the deletion');
73        $this->assertFalse($delInfo['timestamp'], 'external deletion has an unknown exact date');
74        $this->assertEquals(
75            $editRev,
76            $changelog->getRelativeRevision($delRev, -1),
77            'the revision before the external deletion should be the last edit'
78        );
79    }
80
81    /**
82     * A current revision's file can have its modification time bumped without any content
83     * change (a backup restore, a git checkout, ...). That must not be recorded as an
84     * external edit: the content is compared against the last revision and, when identical,
85     * the file mtime is reset to the recorded revision date instead (issue #4634).
86     */
87    public function testTouchedFileWithUnchangedContentIsNotExternalEdit()
88    {
89        $page = 'changelog_touched';
90        saveWikiText($page, 'first content', 'create', false);
91
92        $changelog = new PageChangeLog($page);
93        $lastRev = $changelog->currentRevision();
94
95        // bump the file mtime forward without changing the content
96        touch(wikiFN($page), $lastRev + 1000);
97        clearstatcache();
98
99        $changelog = new PageChangeLog($page);
100        $currentRev = $changelog->currentRevision();
101        $currentInfo = $changelog->getRevisionInfo($currentRev);
102
103        $this->assertEquals($lastRev, $currentRev, 'unchanged content must not create an external revision');
104        $this->assertArrayNotHasKey('timestamp', $currentInfo, 'should not be a synthesized external edit');
105        $this->assertCount(1, $changelog->getRevisions(-1, 200), 'no external edit entry should be added');
106
107        clearstatcache();
108        $this->assertEquals($lastRev, filemtime(wikiFN($page)), 'file mtime should be reset to the changelog date');
109    }
110
111    /**
112     * A deleted page that is restored externally with a preserved mtime predating the
113     * deletion (as cp -p from an older backup does) has content identical to the attic
114     * copy of the delete revision — but that copy holds the pre-delete content, so the
115     * unchanged-content shortcut must not treat it as the still-deleted current revision.
116     * It has to be detected as an external recreation, or the page stays "deleted" forever.
117     */
118    public function testExternallyRestoredDeletedPageIsNotStillDeleted()
119    {
120        $page = 'changelog_extrestored';
121        saveWikiText($page, 'first content', 'create', false);
122        $this->waitForTick(true);
123        saveWikiText($page, 'second content longer', 'edit', false);
124        $this->waitForTick(true);
125
126        // exact bytes the page held right before deletion (== what the delete archives)
127        $content = file_get_contents(wikiFN($page));
128
129        saveWikiText($page, '', 'delete', false);
130        clearstatcache();
131
132        $changelog = new PageChangeLog($page);
133        $delRev = $changelog->currentRevision();
134        $this->assertEquals(
135            DOKU_CHANGE_TYPE_DELETE,
136            $changelog->getRevisionInfo($delRev)['type'],
137            'precondition: the page is recorded as deleted'
138        );
139
140        // restore the pre-delete content externally, with an mtime older than the deletion
141        file_put_contents(wikiFN($page), $content);
142        touch(wikiFN($page), $delRev - 100);
143        clearstatcache();
144
145        $changelog = new PageChangeLog($page);
146        $currentRev = $changelog->currentRevision();
147        $currentInfo = $changelog->getRevisionInfo($currentRev);
148
149        $this->assertEquals(
150            DOKU_CHANGE_TYPE_CREATE,
151            $currentInfo['type'],
152            'the restored page must be detected as an external recreation, not stay deleted'
153        );
154        $this->assertGreaterThan(
155            $delRev,
156            $currentRev,
157            'the recreation is dated after the delete (the restored mtime is unreliable)'
158        );
159        $this->assertFalse($currentInfo['timestamp'], 'the recreation has an unknown exact date');
160        $this->assertEquals(
161            strlen($content),
162            $currentInfo['sizechange'],
163            'the whole restored file counts as the size change, not zero against the delete attic'
164        );
165
166        // and it stays undeleted on the next read (the file mtime was fixed forward, not left
167        // at the delete revision where it would collapse back onto the DELETE entry)
168        clearstatcache();
169        $changelog = new PageChangeLog($page);
170        $this->assertEquals(
171            DOKU_CHANGE_TYPE_CREATE,
172            $changelog->getCurrentRevisionInfo()['type'],
173            'the page must remain undeleted on subsequent reads'
174        );
175    }
176
177    /**
178     * A deleted page recreated externally with an mtime after the deletion (as a git checkout,
179     * which stamps "now", does) is an external creation dated at that file mtime.
180     */
181    public function testExternallyRecreatedDeletedPageWithNewerMtimeIsCreation()
182    {
183        $page = 'changelog_extrecreated';
184        saveWikiText($page, 'first content', 'create', false);
185        $this->waitForTick(true);
186
187        saveWikiText($page, '', 'delete', false);
188        clearstatcache();
189
190        $changelog = new PageChangeLog($page);
191        $delRev = $changelog->currentRevision();
192        $this->assertEquals(
193            DOKU_CHANGE_TYPE_DELETE,
194            $changelog->getRevisionInfo($delRev)['type'],
195            'precondition: the page is recorded as deleted'
196        );
197
198        // recreate the page externally with an mtime after the deletion
199        $extRev = $delRev + 100;
200        file_put_contents(wikiFN($page), 'recreated content');
201        touch(wikiFN($page), $extRev);
202        clearstatcache();
203
204        $changelog = new PageChangeLog($page);
205        $currentRev = $changelog->currentRevision();
206        $currentInfo = $changelog->getRevisionInfo($currentRev);
207
208        $this->assertEquals(DOKU_CHANGE_TYPE_CREATE, $currentInfo['type'], 'recreation after a delete is a creation');
209        $this->assertEquals($extRev, $currentRev, 'the newer file mtime is trusted as the creation date');
210        $this->assertEquals($extRev, $currentInfo['timestamp'], 'the creation has a known date (the file mtime)');
211        $this->assertEquals(strlen('recreated content'), $currentInfo['sizechange']);
212    }
213
214    /**
215     * A page file that appears with no changelog at all (dropped straight into data/pages) is an
216     * external creation dated at its file mtime.
217     */
218    public function testExternallyCreatedFileWithoutChangelogIsCreation()
219    {
220        $page = 'changelog_extcreated';
221        $content = 'externally created content';
222        io_saveFile(wikiFN($page), $content); // write the page file only, no changelog
223        $extRev = time() - 100;
224        touch(wikiFN($page), $extRev);
225        clearstatcache();
226
227        $changelog = new PageChangeLog($page);
228        $currentRev = $changelog->currentRevision();
229        $currentInfo = $changelog->getRevisionInfo($currentRev);
230
231        $this->assertEquals($extRev, $currentRev, 'a bare file is detected as created at its mtime');
232        $this->assertEquals(DOKU_CHANGE_TYPE_CREATE, $currentInfo['type'], 'should be an external creation');
233        $this->assertEquals($extRev, $currentInfo['timestamp'], 'the file mtime is the creation date');
234        $this->assertEquals(strlen($content), $currentInfo['sizechange']);
235    }
236
237    /**
238     * An external edit whose file mtime is older than the last recorded revision (an erroneous
239     * occurrence, e.g. an old backup written over a newer page) cannot trust that mtime as the
240     * date: it is recorded as an external edit just after the last revision, with an unknown date.
241     */
242    public function testExternalEditWithOlderMtimeGetsUnknownDate()
243    {
244        $page = 'changelog_exteditold';
245        saveWikiText($page, 'first content', 'create', false);
246        $this->waitForTick(true);
247        saveWikiText($page, 'second content longer', 'edit', false);
248        clearstatcache();
249
250        $changelog = new PageChangeLog($page);
251        $lastRev = $changelog->currentRevision();
252
253        // change the content externally, but with an mtime older than the last revision
254        file_put_contents(wikiFN($page), 'externally changed content');
255        touch(wikiFN($page), $lastRev - 100);
256        clearstatcache();
257
258        // the erroneous older-than-last mtime is reported as a warning
259        $this->expectLogMessage('current file modification time is older than last revision date');
260
261        $changelog = new PageChangeLog($page);
262        $currentRev = $changelog->currentRevision();
263        $currentInfo = $changelog->getRevisionInfo($currentRev);
264
265        $this->assertEquals(DOKU_CHANGE_TYPE_EDIT, $currentInfo['type'], 'a changed file over a live page is an edit');
266        $this->assertFalse($currentInfo['timestamp'], 'an older-than-last mtime cannot be trusted as the date');
267        $this->assertEquals($lastRev + 1, $currentRev, 'the edit is dated just after the last revision');
268    }
269
270    /**
271     * A detected external edit whose date predates the most recent change already recorded
272     * in the global changelog must stay out of the recent-changes feed (or it would appear
273     * above newer entries with an old date), but is still recorded in the page's own
274     * changelog (issue #4634).
275     */
276    public function testOutOfOrderExternalEditKeptOutOfGlobalChangelog()
277    {
278        global $conf;
279        $page = 'changelog_outoforder';
280        saveWikiText($page, 'first content', 'create', false);
281
282        $changelog = new PageChangeLog($page);
283        $createRev = $changelog->currentRevision();
284
285        // external edit with different content, dated after the create but well before the
286        // global changelog's last-modified time
287        $globalFile = $conf['changelog'];
288        $extRev = $createRev + 10;
289        file_put_contents(wikiFN($page), 'externally edited content');
290        touch(wikiFN($page), $extRev);
291        touch($globalFile, $createRev + 100000);
292        clearstatcache();
293
294        $changelog = new PageChangeLog($page);
295        $detectedRev = $changelog->currentRevision();
296        $detectedInfo = $changelog->getRevisionInfo($detectedRev);
297
298        // detected and recorded in the page's own changelog: the create plus the external edit
299        $this->assertEquals($extRev, $detectedRev, 'external edit should be detected at the file mtime');
300        $this->assertEquals(DOKU_CHANGE_TYPE_EDIT, $detectedInfo['type'], 'should be an external edit');
301        $this->assertEquals(
302            [$extRev, $createRev],
303            $changelog->getRevisions(-1, 200),
304            'page changelog should hold the create and the external edit'
305        );
306
307        // ...but kept out of the global recent-changes feed
308        $this->assertStringNotContainsString(
309            "$extRev\t",
310            file_get_contents($globalFile),
311            'out-of-order external edit must not be appended to the global changelog'
312        );
313    }
314
315    /**
316     * A genuinely current external edit (dated at or after the global changelog's last
317     * recorded change) must still reach the recent-changes feed (issue #4634).
318     */
319    public function testCurrentExternalEditReachesGlobalChangelog()
320    {
321        global $conf;
322        $page = 'changelog_freshext';
323        saveWikiText($page, 'first content', 'create', false);
324
325        $changelog = new PageChangeLog($page);
326        $createRev = $changelog->currentRevision();
327
328        // external edit dated after the create, so it is newer than the feed's most recent
329        // change (the create just written there) and should be appended
330        $extRev = $createRev + 100;
331        file_put_contents(wikiFN($page), 'externally edited content');
332        touch(wikiFN($page), $extRev);
333        clearstatcache();
334
335        $changelog = new PageChangeLog($page);
336        $changelog->currentRevision();
337
338        $this->assertStringContainsString(
339            "$extRev\t",
340            file_get_contents($conf['changelog']),
341            'a current external edit should be appended to the global changelog'
342        );
343    }
344}
345