1<?php 2 3namespace dokuwiki\plugin\annotations\test; 4 5use DokuWikiTest; 6 7/** 8 * Storage, CRUD, permission and orphan-detection tests for the annotations 9 * helper. The helper is pure logic, so most of this needs no HTTP request. 10 * 11 * @group plugin_annotations 12 * @group plugins 13 */ 14class HelperTest extends DokuWikiTest 15{ 16 protected $pluginsEnabled = ['annotations']; 17 18 /** 19 * @return \helper_plugin_annotations 20 */ 21 protected function helper() 22 { 23 return new \helper_plugin_annotations(); 24 } 25 26 // ----------------------------------------------------------------- 27 // Permission rules (pure functions) 28 // ----------------------------------------------------------------- 29 30 public function testCanAnnotateRequiresLoginAndRead(): void 31 { 32 $h = $this->helper(); 33 $this->assertFalse($h->canAnnotate('', AUTH_READ), 'anonymous may not annotate'); 34 $this->assertFalse($h->canAnnotate('alice', AUTH_NONE), 'no read access → no annotate'); 35 $this->assertTrue($h->canAnnotate('alice', AUTH_READ), 'logged in + read → annotate'); 36 $this->assertTrue($h->canAnnotate('alice', AUTH_EDIT), 'edit access implies read'); 37 } 38 39 public function testCanEditAnnotationAuthorOrAdmin(): void 40 { 41 $h = $this->helper(); 42 $ann = ['author' => 'alice']; 43 $this->assertTrue($h->canEditAnnotation($ann, 'alice', false), 'author may edit'); 44 $this->assertFalse($h->canEditAnnotation($ann, 'bob', false), 'non-author may not edit'); 45 $this->assertTrue($h->canEditAnnotation($ann, 'bob', true), 'admin may edit anyone'); 46 $this->assertFalse($h->canEditAnnotation($ann, '', true), 'anonymous never edits'); 47 } 48 49 public function testCanEditReplyAuthorOrAdmin(): void 50 { 51 $h = $this->helper(); 52 $reply = ['author' => 'alice']; 53 $this->assertTrue($h->canEditReply($reply, 'alice', false)); 54 $this->assertFalse($h->canEditReply($reply, 'bob', false)); 55 $this->assertTrue($h->canEditReply($reply, 'bob', true)); 56 } 57 58 public function testCanClearAdminOnly(): void 59 { 60 $h = $this->helper(); 61 $this->assertTrue($h->canClear(true)); 62 $this->assertFalse($h->canClear(false)); 63 } 64 65 // ----------------------------------------------------------------- 66 // Annotation CRUD 67 // ----------------------------------------------------------------- 68 69 public function testCreateGetAndStats(): void 70 { 71 $h = $this->helper(); 72 $id = 'anntest:crud'; 73 74 $ann = $h->createAnnotation($id, ['exact' => 'hello world'], 'alice', 'A comment'); 75 $this->assertIsArray($ann); 76 $this->assertNotEmpty($ann['id']); 77 $this->assertEquals('open', $ann['status']); 78 $this->assertEquals('alice', $ann['author']); 79 80 $this->assertCount(1, $h->getAnnotations($id)); 81 $this->assertEquals($ann['id'], $h->getAnnotation($id, $ann['id'])['id']); 82 $this->assertEquals(['total' => 1, 'open' => 1, 'resolved' => 0], $h->getStats($id)); 83 } 84 85 public function testCreateRejectsEmptyBodyAnchorOrAuthor(): void 86 { 87 $h = $this->helper(); 88 $id = 'anntest:reject'; 89 90 $this->assertFalse($h->createAnnotation($id, ['exact' => 'x'], 'alice', ' '), 'empty body'); 91 $this->assertFalse($h->createAnnotation($id, ['exact' => ''], 'alice', 'body'), 'empty exact'); 92 $this->assertFalse($h->createAnnotation($id, ['exact' => 'x'], '', 'body'), 'empty author'); 93 $this->assertSame([], $h->getAnnotations($id), 'nothing was stored'); 94 } 95 96 public function testBodyAndQuoteAreLengthCapped(): void 97 { 98 $h = $this->helper(); 99 $id = 'anntest:caps'; 100 101 $ann = $h->createAnnotation($id, ['exact' => str_repeat('q', 2000)], 'alice', str_repeat('x', 20000)); 102 $this->assertIsArray($ann); 103 $this->assertEquals(10000, mb_strlen($ann['body']), 'body capped at MAX_BODY'); 104 $this->assertEquals(1000, mb_strlen($ann['anchor']['exact']), 'quote capped at MAX_QUOTE'); 105 } 106 107 public function testWhitespaceNormalisedInAnchor(): void 108 { 109 $h = $this->helper(); 110 $id = 'anntest:ws'; 111 $ann = $h->createAnnotation($id, ['exact' => " foo\n\t bar "], 'alice', 'b'); 112 $this->assertEquals('foo bar', $ann['anchor']['exact']); 113 } 114 115 public function testUpdateAndDeleteAnnotation(): void 116 { 117 $h = $this->helper(); 118 $id = 'anntest:upd'; 119 $ann = $h->createAnnotation($id, ['exact' => 'foo'], 'alice', 'first'); 120 121 $this->assertTrue($h->updateAnnotationBody($id, $ann['id'], 'second')); 122 $this->assertEquals('second', $h->getAnnotation($id, $ann['id'])['body']); 123 $this->assertFalse($h->updateAnnotationBody($id, 'nope', 'x'), 'missing id → false'); 124 $this->assertFalse($h->updateAnnotationBody($id, $ann['id'], ' '), 'empty body → false'); 125 126 $this->assertTrue($h->deleteAnnotation($id, $ann['id'])); 127 $this->assertNull($h->getAnnotation($id, $ann['id'])); 128 $this->assertFalse($h->deleteAnnotation($id, $ann['id']), 'already gone → false'); 129 } 130 131 public function testStatusFlow(): void 132 { 133 $h = $this->helper(); 134 $id = 'anntest:status'; 135 $ann = $h->createAnnotation($id, ['exact' => 'foo'], 'alice', 'b'); 136 137 $this->assertTrue($h->setStatus($id, $ann['id'], 'resolved', 'bob')); 138 $resolved = $h->getAnnotation($id, $ann['id']); 139 $this->assertEquals('resolved', $resolved['status']); 140 $this->assertEquals('bob', $resolved['resolved_by']); 141 $this->assertGreaterThan(0, $resolved['resolved_at']); 142 143 $this->assertTrue($h->setStatus($id, $ann['id'], 'open', 'bob')); 144 $reopened = $h->getAnnotation($id, $ann['id']); 145 $this->assertEquals('open', $reopened['status']); 146 $this->assertEquals('', $reopened['resolved_by']); 147 148 $this->assertFalse($h->setStatus($id, $ann['id'], 'bogus', 'bob'), 'invalid status → false'); 149 } 150 151 // ----------------------------------------------------------------- 152 // Reply CRUD 153 // ----------------------------------------------------------------- 154 155 public function testReplyCrud(): void 156 { 157 $h = $this->helper(); 158 $id = 'anntest:reply'; 159 $ann = $h->createAnnotation($id, ['exact' => 'foo'], 'alice', 'b'); 160 161 $reply = $h->addReply($id, $ann['id'], 'bob', 'a reply'); 162 $this->assertIsArray($reply); 163 $this->assertNotEmpty($reply['id']); 164 $this->assertCount(1, $h->getAnnotation($id, $ann['id'])['replies']); 165 166 $this->assertTrue($h->updateReply($id, $ann['id'], $reply['id'], 'edited reply')); 167 $this->assertEquals('edited reply', $h->getAnnotation($id, $ann['id'])['replies'][0]['body']); 168 169 $this->assertTrue($h->deleteReply($id, $ann['id'], $reply['id'])); 170 $this->assertCount(0, $h->getAnnotation($id, $ann['id'])['replies']); 171 172 $this->assertFalse($h->addReply($id, 'missing-ann', 'bob', 'x'), 'reply to missing annotation → false'); 173 } 174 175 // ----------------------------------------------------------------- 176 // Bulk maintenance 177 // ----------------------------------------------------------------- 178 179 public function testClearResolved(): void 180 { 181 $h = $this->helper(); 182 $id = 'anntest:clearres'; 183 $keep = $h->createAnnotation($id, ['exact' => 'one'], 'alice', 'b1'); 184 $drop = $h->createAnnotation($id, ['exact' => 'two'], 'alice', 'b2'); 185 $h->setStatus($id, $drop['id'], 'resolved', 'alice'); 186 187 $this->assertEquals(1, $h->clearResolved($id)); 188 $remaining = $h->getAnnotations($id); 189 $this->assertCount(1, $remaining); 190 $this->assertEquals($keep['id'], $remaining[0]['id']); 191 } 192 193 public function testClearResolvedAllAcrossPages(): void 194 { 195 $h = $this->helper(); 196 $ids = ['anntest:resweepa', 'anntest:resweepb']; 197 foreach ($ids as $id) { 198 $h->createAnnotation($id, ['exact' => 'keep open'], 'alice', 'open'); 199 $done = $h->createAnnotation($id, ['exact' => 'mark done'], 'alice', 'done'); 200 $h->setStatus($id, $done['id'], 'resolved', 'alice'); 201 } 202 203 // clearResolvedAll() is wiki-wide and the shared test data dir may carry 204 // resolved annotations from earlier tests, so assert the seeded ones are 205 // included (>= 2) rather than an exact global total, and verify each of 206 // our pages keeps exactly its one open annotation. 207 $removed = $h->clearResolvedAll(); 208 $this->assertGreaterThanOrEqual(2, $removed, 'at least the two seeded resolved are cleared'); 209 foreach ($ids as $id) { 210 $remaining = $h->getAnnotations($id); 211 $this->assertCount(1, $remaining, $id . ' keeps only the open annotation'); 212 $this->assertEquals('open', $remaining[0]['body'], $id . ' kept the right annotation'); 213 } 214 } 215 216 // ----------------------------------------------------------------- 217 // Orphan detection against a rendered page 218 // ----------------------------------------------------------------- 219 220 public function testFindAndClearOrphanedAgainstRenderedPage(): void 221 { 222 $id = 'anntest:orphan'; 223 saveWikiText($id, 'Hello world, this is a wiki page about cats.', 'setup'); 224 225 $h = $this->helper(); 226 $present = $h->createAnnotation($id, ['exact' => 'wiki page about cats'], 'alice', 'present'); 227 $gone = $h->createAnnotation($id, ['exact' => 'text that is not here'], 'alice', 'gone'); 228 229 $orphanIds = array_map(static function ($a) { 230 return $a['id']; 231 }, $h->findOrphaned($id)); 232 233 $this->assertContains($gone['id'], $orphanIds, 'a missing quote is orphaned'); 234 $this->assertNotContains($present['id'], $orphanIds, 'a present quote is not orphaned'); 235 236 $this->assertEquals(1, $h->clearOrphaned($id), 'only the orphan is cleared'); 237 $remaining = $h->getAnnotations($id); 238 $this->assertCount(1, $remaining); 239 $this->assertEquals($present['id'], $remaining[0]['id']); 240 } 241 242 // ----------------------------------------------------------------- 243 // Admin overview (enumeration & counts) 244 // ----------------------------------------------------------------- 245 246 public function testGetAnnotatedPages(): void 247 { 248 $h = $this->helper(); 249 $h->createAnnotation('anntest:listone', ['exact' => 'foo'], 'alice', 'b'); 250 $h->createAnnotation('anntest:nested:listtwo', ['exact' => 'bar'], 'alice', 'b'); 251 252 // a page whose only annotation is then deleted leaves an empty file and 253 // must NOT be listed 254 $gone = $h->createAnnotation('anntest:emptied', ['exact' => 'baz'], 'alice', 'b'); 255 $h->deleteAnnotation('anntest:emptied', $gone['id']); 256 257 $pages = $h->getAnnotatedPages(); 258 $this->assertContains('anntest:listone', $pages); 259 $this->assertContains('anntest:nested:listtwo', $pages); 260 $this->assertNotContains('anntest:emptied', $pages, 'emptied page is excluded'); 261 } 262 263 public function testPageCountsSplitsNormalAndOrphaned(): void 264 { 265 $id = 'anntest:counts'; 266 saveWikiText($id, 'Hello world, this is a wiki page about cats.', 'setup'); 267 268 $h = $this->helper(); 269 $h->createAnnotation($id, ['exact' => 'wiki page about cats'], 'alice', 'present'); 270 $h->createAnnotation($id, ['exact' => 'text that is not here'], 'alice', 'gone'); 271 272 $this->assertEquals( 273 ['total' => 2, 'normal' => 1, 'resolved' => 0, 'orphaned' => 1], 274 $h->pageCounts($id) 275 ); 276 } 277 278 public function testPageCountsCountsResolvedAcrossFacets(): void 279 { 280 $id = 'anntest:countsres'; 281 saveWikiText($id, 'Hello world, this is a wiki page about cats.', 'setup'); 282 283 $h = $this->helper(); 284 // present + resolved → counted in both normal and resolved 285 $present = $h->createAnnotation($id, ['exact' => 'wiki page about cats'], 'alice', 'present'); 286 $h->setStatus($id, $present['id'], 'resolved', 'alice'); 287 // orphaned + resolved → counted in both orphaned and resolved 288 $gone = $h->createAnnotation($id, ['exact' => 'text that is not here'], 'alice', 'gone'); 289 $h->setStatus($id, $gone['id'], 'resolved', 'alice'); 290 291 $this->assertEquals( 292 ['total' => 2, 'normal' => 1, 'resolved' => 2, 'orphaned' => 1], 293 $h->pageCounts($id) 294 ); 295 } 296 297 public function testPageCountsEmptyForUnannotatedPage(): void 298 { 299 $h = $this->helper(); 300 $this->assertEquals( 301 ['total' => 0, 'normal' => 0, 'resolved' => 0, 'orphaned' => 0], 302 $h->pageCounts('anntest:never') 303 ); 304 } 305 306 public function testClearOrphanedAllAcrossPages(): void 307 { 308 $h = $this->helper(); 309 $ids = ['anntest:sweepa', 'anntest:sweepb']; 310 foreach ($ids as $id) { 311 saveWikiText($id, 'Hello world, this is a wiki page about cats.', 'setup'); 312 $h->createAnnotation($id, ['exact' => 'wiki page about cats'], 'alice', 'present'); 313 $h->createAnnotation($id, ['exact' => 'text that is not here'], 'alice', 'gone'); 314 } 315 316 // clearOrphanedAll() is wiki-wide and the shared test data dir may carry 317 // annotated pages from earlier tests, so assert the seeded orphans are 318 // included (>= 2) rather than an exact global total, and verify each of 319 // our pages keeps exactly its one present annotation. 320 $removed = $h->clearOrphanedAll(); 321 $this->assertGreaterThanOrEqual(2, $removed, 'at least the two seeded orphans are cleared'); 322 foreach ($ids as $id) { 323 $remaining = $h->getAnnotations($id); 324 $this->assertCount(1, $remaining, $id . ' keeps only the present annotation'); 325 $this->assertEquals('present', $remaining[0]['body'], $id . ' kept the right annotation'); 326 $this->assertSame(0, $h->pageCounts($id)['orphaned'], $id . ' has no orphans left'); 327 } 328 } 329} 330